R101/A1-A7 收口企业微信移动端体验
@@ -0,0 +1,492 @@
|
||||
{
|
||||
"baseURL": "https://oa.hyinda.com",
|
||||
"generatedAt": "2026-05-06T13:35:34.261Z",
|
||||
"login": {
|
||||
"user": {
|
||||
"identifier": "[email protected]",
|
||||
"name": "employee"
|
||||
},
|
||||
"attempts": [
|
||||
{
|
||||
"user": "employee",
|
||||
"url": "https://oa.hyinda.com/portal"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"id": "pc-reimbursements-list",
|
||||
"url": "https://oa.hyinda.com/reimbursements",
|
||||
"title": "海音达工作台",
|
||||
"textStart": "海音达工作台\n审批 报销 团队\n今日工作台\n先处理待办和团队状态,再进入具体模块。\n主导航\n优先处理今日工作与高频协同。\nhome_app_logo\nPortal\ngroups\n团队\nevent_available\n请假\nrequest_quote\n报销\ninventory_2\n资产\narticle\n周报\ncheck_circle\n待办\ncampaign\n公告\nworkspaces\n项目\n备用模块\n默认收起,不干扰主路径\n按需展开\n主导航\n\n报销\n\n你好,Employee\nsearch\n搜索\nEmployee\n我的报销\n\n草稿、审批、支付。\n\n新建报销\n导出\n当前列表\n0\n共 0 条\n当前金额\n¥0.00\n当前页\n审批中\n0\n待推进\n已支付\n0\n仅自己\n草稿\n审批中\n已通过\n已支付\n已驳回\n已取消\n单号\t状态\t金额\t报销人\t日期\t操作\n暂无数据\n第 1 / 1 页\n上一页\n下一页",
|
||||
"bodyTextLength": 395,
|
||||
"buttons": [
|
||||
"search搜索",
|
||||
"search",
|
||||
"Employee",
|
||||
"新建报销",
|
||||
"导出",
|
||||
"草稿",
|
||||
"审批中",
|
||||
"已通过",
|
||||
"已支付",
|
||||
"已驳回",
|
||||
"已取消",
|
||||
"上一页",
|
||||
"下一页"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"text": "home_app_logoPortal",
|
||||
"href": "/portal"
|
||||
},
|
||||
{
|
||||
"text": "groups团队",
|
||||
"href": "/team"
|
||||
},
|
||||
{
|
||||
"text": "event_available请假",
|
||||
"href": "/leave-requests"
|
||||
},
|
||||
{
|
||||
"text": "request_quote报销",
|
||||
"href": "/reimbursements"
|
||||
},
|
||||
{
|
||||
"text": "inventory_2资产",
|
||||
"href": "/assets"
|
||||
},
|
||||
{
|
||||
"text": "article周报",
|
||||
"href": "/weekly-reports"
|
||||
},
|
||||
{
|
||||
"text": "check_circle待办",
|
||||
"href": "/todos"
|
||||
},
|
||||
{
|
||||
"text": "campaign公告",
|
||||
"href": "/announcements"
|
||||
},
|
||||
{
|
||||
"text": "workspaces项目",
|
||||
"href": "/projects"
|
||||
},
|
||||
{
|
||||
"text": "shopping_cart采购申请",
|
||||
"href": "/purchase-requests"
|
||||
},
|
||||
{
|
||||
"text": "flight差旅申请",
|
||||
"href": "/travel-requests"
|
||||
},
|
||||
{
|
||||
"text": "receipt_long票据中心",
|
||||
"href": "/invoice-pool"
|
||||
}
|
||||
],
|
||||
"tables": [
|
||||
{
|
||||
"headers": [
|
||||
"单号",
|
||||
"状态",
|
||||
"金额",
|
||||
"报销人",
|
||||
"日期",
|
||||
"操作"
|
||||
],
|
||||
"firstRow": [
|
||||
"暂无数据"
|
||||
],
|
||||
"rowCount": 1
|
||||
}
|
||||
],
|
||||
"dialogs": [],
|
||||
"longUuidLike": [],
|
||||
"hasDeleteText": false,
|
||||
"hasReimbursementDelete": false,
|
||||
"hasInvoiceOptionalBinding": false,
|
||||
"verboseFlags": {
|
||||
"hasViewEditSubmit": false,
|
||||
"hasInvoiceWaitOcrCopy": false,
|
||||
"hasCurrentPageMetrics": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pc-invoice-pool",
|
||||
"url": "https://oa.hyinda.com/invoice-pool",
|
||||
"title": "海音达工作台",
|
||||
"textStart": "海音达工作台\n审批 报销 团队\n今日工作台\n先处理待办和团队状态,再进入具体模块。\n主导航\n优先处理今日工作与高频协同。\nhome_app_logo\nPortal\ngroups\n团队\nevent_available\n请假\nrequest_quote\n报销\ninventory_2\n资产\narticle\n周报\ncheck_circle\n待办\ncampaign\n公告\nworkspaces\n项目\n备用模块\n默认收起,不干扰主路径\n按需展开\n工作台\n\n/invoice-pool\n\n你好,Employee\nsearch\n搜索\nEmployee\n票据中心\n\n上传、识别、绑定。\n\n导出\n添加发票\ncloud_upload\n选择文件\n上传\n票据列表\n全部状态\n识别中\n可用\n识别失败\n已绑定\n已归档\n仅看自己\n查看全部\n票据流水号\t状态\t发票号码\t识别状态\t操作\n\nc5f3e528-8622-46d0-87c5-50af3c3b5dfd\n\t可用\t26112000000938423851-26112000000938423851\t已识别\tmore_horiz\n\n3e660edf-d29a-4683-bd99-e8e13b1e614e\n\t可用\t26112000000938423851-26112000000938423851\t已识别\tmore_horiz\n\n3c0ba5ae-e01b-4a58-8a6f-0e137134d0a0\n\t可用\t26112000000938423851-26112000000938423851\t已识别\tmore_horiz\n第 1 / 1 页\n上一页\n下一页\n绑定业务单据\n选择票据\n26112000000938423851-26112000000938423851 (available)\n26112000000938423851-26112000000938423851 (available)\n26112000000938423851-26112000000938423851 (available)\n选择报销单\n绑定票据\n票据详情\n票据 ID:c5f3e528-8622-46d0-87c5-50af3c3b5dfd\n状态:available\n发票号:26112000000938423851-26112000000938423851\n销售方:北京悦海堂餐饮管理有限公司\n购买方:合肥海音达科技有限公司\n总金额:¥1,683.00\n开票日期:2026-03-11\n关联报销:-\n验真状态:未触发\n判重例外:否\n人工修正\n手工归档",
|
||||
"bodyTextLength": 1080,
|
||||
"buttons": [
|
||||
"search搜索",
|
||||
"search",
|
||||
"Employee",
|
||||
"导出",
|
||||
"上传",
|
||||
"查看详情",
|
||||
"预览",
|
||||
"查看详情",
|
||||
"预览",
|
||||
"查看详情",
|
||||
"预览",
|
||||
"more_horiz",
|
||||
"more_horiz",
|
||||
"more_horiz",
|
||||
"上一页",
|
||||
"下一页",
|
||||
"绑定票据",
|
||||
"人工修正",
|
||||
"手工归档"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"text": "home_app_logoPortal",
|
||||
"href": "/portal"
|
||||
},
|
||||
{
|
||||
"text": "groups团队",
|
||||
"href": "/team"
|
||||
},
|
||||
{
|
||||
"text": "event_available请假",
|
||||
"href": "/leave-requests"
|
||||
},
|
||||
{
|
||||
"text": "request_quote报销",
|
||||
"href": "/reimbursements"
|
||||
},
|
||||
{
|
||||
"text": "inventory_2资产",
|
||||
"href": "/assets"
|
||||
},
|
||||
{
|
||||
"text": "article周报",
|
||||
"href": "/weekly-reports"
|
||||
},
|
||||
{
|
||||
"text": "check_circle待办",
|
||||
"href": "/todos"
|
||||
},
|
||||
{
|
||||
"text": "campaign公告",
|
||||
"href": "/announcements"
|
||||
},
|
||||
{
|
||||
"text": "workspaces项目",
|
||||
"href": "/projects"
|
||||
},
|
||||
{
|
||||
"text": "shopping_cart采购申请",
|
||||
"href": "/purchase-requests"
|
||||
},
|
||||
{
|
||||
"text": "flight差旅申请",
|
||||
"href": "/travel-requests"
|
||||
},
|
||||
{
|
||||
"text": "receipt_long票据中心",
|
||||
"href": "/invoice-pool"
|
||||
}
|
||||
],
|
||||
"tables": [
|
||||
{
|
||||
"headers": [
|
||||
"票据流水号",
|
||||
"状态",
|
||||
"发票号码",
|
||||
"识别状态",
|
||||
"操作"
|
||||
],
|
||||
"firstRow": [
|
||||
"c5f3e528-8622-46d0-87c5-50af3c3b5dfd",
|
||||
"可用",
|
||||
"26112000000938423851-26112000000938423851",
|
||||
"已识别",
|
||||
"more_horiz"
|
||||
],
|
||||
"rowCount": 3
|
||||
}
|
||||
],
|
||||
"dialogs": [],
|
||||
"longUuidLike": [
|
||||
"c5f3e528-8622-46d0-87c5-50af3c3b5dfd",
|
||||
"3e660edf-d29a-4683-bd99-e8e13b1e614e",
|
||||
"3c0ba5ae-e01b-4a58-8a6f-0e137134d0a0"
|
||||
],
|
||||
"hasDeleteText": false,
|
||||
"hasReimbursementDelete": false,
|
||||
"hasInvoiceOptionalBinding": false,
|
||||
"verboseFlags": {
|
||||
"hasViewEditSubmit": false,
|
||||
"hasInvoiceWaitOcrCopy": false,
|
||||
"hasCurrentPageMetrics": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pc-leave-requests",
|
||||
"url": "https://oa.hyinda.com/leave-requests",
|
||||
"title": "海音达工作台",
|
||||
"textStart": "海音达工作台\n审批 报销 团队\n今日工作台\n先处理待办和团队状态,再进入具体模块。\n主导航\n优先处理今日工作与高频协同。\nhome_app_logo\nPortal\ngroups\n团队\nevent_available\n请假\nrequest_quote\n报销\ninventory_2\n资产\narticle\n周报\ncheck_circle\n待办\ncampaign\n公告\nworkspaces\n项目\n备用模块\n默认收起,不干扰主路径\n按需展开\n主导航\n\n请假\n\n你好,Employee\nsearch\n搜索\nEmployee\n请假申请\n\n支持假种选择、半天粒度请假、额度冻结与撤回释放。\n\n可用额度\n30.0\n按当前启用假种汇总\n已冻结\n0.0\n审批中申请会占用额度\n已使用\n0.0\n通过后计入已使用\n本次预估\n-\n创建草稿前可先预览天数\n新建请假草稿\n\n先填写时间段与假种,再补充附件和说明。\n\n假期类型\n请选择\n年假\n病假\n事假\n开始日期\n开始时段\n上午\n下午\n结束日期\n结束时段\n上午\n下午\n附件\n\n附件为可选项\n\n请假说明\n预计请假天数:-\n创建草稿\n额度概览\n\n假种可用额度、冻结与已使用情况按自然周期汇总。\n\n年假\n\n2026-01-01 ~ 2026-12-31\n\n启用\n\n可用\n\n15\n\n已冻结\n\n0\n\n已使用\n\n0\n\n授予额度\n\n15\n\n病假\n\n2026-01-01 ~ 2026-12-31\n\n启用\n\n可用\n\n10\n\n已冻结\n\n0\n\n已使用\n\n0\n\n授予额度\n\n10\n\n事假\n\n2026-01-01 ~ 2026-12-31\n\n启用\n\n可用\n\n5\n\n已冻结\n\n0\n\n已使用\n\n0\n\n授予额度\n\n5\n\n请假记录\n\n支持按状态和范围收敛列表,草稿与驳回单可再次提交。\n\n最近一次筛选会保留在当前页面状态中。\n全部状态\n草稿\n审批中\n已通过\n已驳回\n已取消\n刷新\n假种 / 申请人\t时间段\t天数\t状态\t附件\t更新时间\t操作\n暂无请假记录\n第 1 / 1 页\n上一页\n下一页",
|
||||
"bodyTextLength": 838,
|
||||
"buttons": [
|
||||
"search搜索",
|
||||
"search",
|
||||
"Employee",
|
||||
"创建草稿",
|
||||
"刷新",
|
||||
"上一页",
|
||||
"下一页"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"text": "home_app_logoPortal",
|
||||
"href": "/portal"
|
||||
},
|
||||
{
|
||||
"text": "groups团队",
|
||||
"href": "/team"
|
||||
},
|
||||
{
|
||||
"text": "event_available请假",
|
||||
"href": "/leave-requests"
|
||||
},
|
||||
{
|
||||
"text": "request_quote报销",
|
||||
"href": "/reimbursements"
|
||||
},
|
||||
{
|
||||
"text": "inventory_2资产",
|
||||
"href": "/assets"
|
||||
},
|
||||
{
|
||||
"text": "article周报",
|
||||
"href": "/weekly-reports"
|
||||
},
|
||||
{
|
||||
"text": "check_circle待办",
|
||||
"href": "/todos"
|
||||
},
|
||||
{
|
||||
"text": "campaign公告",
|
||||
"href": "/announcements"
|
||||
},
|
||||
{
|
||||
"text": "workspaces项目",
|
||||
"href": "/projects"
|
||||
},
|
||||
{
|
||||
"text": "shopping_cart采购申请",
|
||||
"href": "/purchase-requests"
|
||||
},
|
||||
{
|
||||
"text": "flight差旅申请",
|
||||
"href": "/travel-requests"
|
||||
},
|
||||
{
|
||||
"text": "receipt_long票据中心",
|
||||
"href": "/invoice-pool"
|
||||
}
|
||||
],
|
||||
"tables": [
|
||||
{
|
||||
"headers": [
|
||||
"假种 / 申请人",
|
||||
"时间段",
|
||||
"天数",
|
||||
"状态",
|
||||
"附件",
|
||||
"更新时间",
|
||||
"操作"
|
||||
],
|
||||
"firstRow": [
|
||||
"暂无请假记录"
|
||||
],
|
||||
"rowCount": 1
|
||||
}
|
||||
],
|
||||
"dialogs": [],
|
||||
"longUuidLike": [],
|
||||
"hasDeleteText": false,
|
||||
"hasReimbursementDelete": false,
|
||||
"hasInvoiceOptionalBinding": false,
|
||||
"verboseFlags": {
|
||||
"hasViewEditSubmit": false,
|
||||
"hasInvoiceWaitOcrCopy": false,
|
||||
"hasCurrentPageMetrics": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pc-purchase-requests",
|
||||
"url": "https://oa.hyinda.com/purchase-requests",
|
||||
"title": "海音达工作台",
|
||||
"textStart": "海音达工作台\n审批 报销 团队\n今日工作台\n先处理待办和团队状态,再进入具体模块。\n主导航\n优先处理今日工作与高频协同。\nhome_app_logo\nPortal\ngroups\n团队\nevent_available\n请假\nrequest_quote\n报销\ninventory_2\n资产\narticle\n周报\ncheck_circle\n待办\ncampaign\n公告\nworkspaces\n项目\n备用模块\n默认收起,不干扰主路径\n按需展开\n工作台\n\n/purchase-requests\n\n你好,Employee\nsearch\n搜索\nEmployee\n采购申请\n\n支持草稿、附件、提交、撤回与经理单步审批的轻采购流程。\n\n新建采购\n全部状态\n草稿\n审批中\n已通过\n已驳回\n已取消\n全部供应商\n全部法体\n试运行法体\n全部成本中心\n采购单列表\n共 0 条\n单据\t申请人\t状态\t金额\n暂无采购申请\n第 1 / 1 页\n上一页\n下一页\n请先在左侧选择采购单,或新建一张采购草稿。",
|
||||
"bodyTextLength": 433,
|
||||
"buttons": [
|
||||
"search搜索",
|
||||
"search",
|
||||
"Employee",
|
||||
"新建采购",
|
||||
"上一页",
|
||||
"下一页"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"text": "home_app_logoPortal",
|
||||
"href": "/portal"
|
||||
},
|
||||
{
|
||||
"text": "groups团队",
|
||||
"href": "/team"
|
||||
},
|
||||
{
|
||||
"text": "event_available请假",
|
||||
"href": "/leave-requests"
|
||||
},
|
||||
{
|
||||
"text": "request_quote报销",
|
||||
"href": "/reimbursements"
|
||||
},
|
||||
{
|
||||
"text": "inventory_2资产",
|
||||
"href": "/assets"
|
||||
},
|
||||
{
|
||||
"text": "article周报",
|
||||
"href": "/weekly-reports"
|
||||
},
|
||||
{
|
||||
"text": "check_circle待办",
|
||||
"href": "/todos"
|
||||
},
|
||||
{
|
||||
"text": "campaign公告",
|
||||
"href": "/announcements"
|
||||
},
|
||||
{
|
||||
"text": "workspaces项目",
|
||||
"href": "/projects"
|
||||
},
|
||||
{
|
||||
"text": "shopping_cart采购申请",
|
||||
"href": "/purchase-requests"
|
||||
},
|
||||
{
|
||||
"text": "flight差旅申请",
|
||||
"href": "/travel-requests"
|
||||
},
|
||||
{
|
||||
"text": "receipt_long票据中心",
|
||||
"href": "/invoice-pool"
|
||||
}
|
||||
],
|
||||
"tables": [
|
||||
{
|
||||
"headers": [
|
||||
"单据",
|
||||
"申请人",
|
||||
"状态",
|
||||
"金额"
|
||||
],
|
||||
"firstRow": [
|
||||
"暂无采购申请"
|
||||
],
|
||||
"rowCount": 1
|
||||
}
|
||||
],
|
||||
"dialogs": [],
|
||||
"longUuidLike": [],
|
||||
"hasDeleteText": false,
|
||||
"hasReimbursementDelete": false,
|
||||
"hasInvoiceOptionalBinding": false,
|
||||
"verboseFlags": {
|
||||
"hasViewEditSubmit": false,
|
||||
"hasInvoiceWaitOcrCopy": false,
|
||||
"hasCurrentPageMetrics": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pc-travel-requests-tab-requests",
|
||||
"url": "https://oa.hyinda.com/travel-requests?tab=requests",
|
||||
"title": "海音达工作台",
|
||||
"textStart": "海音达工作台\n审批 报销 团队\n今日工作台\n先处理待办和团队状态,再进入具体模块。\n主导航\n优先处理今日工作与高频协同。\nhome_app_logo\nPortal\ngroups\n团队\nevent_available\n请假\nrequest_quote\n报销\ninventory_2\n资产\narticle\n周报\ncheck_circle\n待办\ncampaign\n公告\nworkspaces\n项目\n备用模块\n默认收起,不干扰主路径\n按需展开\n工作台\n\n/travel-requests\n\n你好,Employee\nsearch\n搜索\nEmployee\n差旅管理\n\n差旅申请、对公支付轨道与批次对账在同一路径统一收口。\n\n新建差旅申请\n差旅申请\n状态\n全部状态\n草稿\n审批中\n已通过\n已驳回\n出差类型\n全部类型\n国内\n国际\n法体\n全部法体\n试运行法体\n成本中心\n全部成本中心\n暂无差旅申请\n\n点击右上角创建新的差旅草稿。\n\n第 1 / 1 页\n上一页\n下一页\n新建差旅申请\n\n填写主单与行程段,保存为草稿后可继续编辑并提交审批。\n\n出差目的\n出差类型\n国内\n国际\n预估金额\n开始日期\n结束日期\n法体\n请选择法体\n试运行法体\n成本中心\n请选择成本中心\n预算池\n待提交时自动解析\n行程段\n新增行程段\n\n第 1 段\n\n角色\n去程\n返程\n中转\n交通方式\n航班\n火车\n汽车\n大巴\n其他\n出发地\n目的地\n出发时间\n到达时间\n保存草稿",
|
||||
"bodyTextLength": 605,
|
||||
"buttons": [
|
||||
"search搜索",
|
||||
"search",
|
||||
"Employee",
|
||||
"新建差旅申请",
|
||||
"差旅申请",
|
||||
"上一页",
|
||||
"下一页",
|
||||
"新增行程段",
|
||||
"保存草稿"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"text": "home_app_logoPortal",
|
||||
"href": "/portal"
|
||||
},
|
||||
{
|
||||
"text": "groups团队",
|
||||
"href": "/team"
|
||||
},
|
||||
{
|
||||
"text": "event_available请假",
|
||||
"href": "/leave-requests"
|
||||
},
|
||||
{
|
||||
"text": "request_quote报销",
|
||||
"href": "/reimbursements"
|
||||
},
|
||||
{
|
||||
"text": "inventory_2资产",
|
||||
"href": "/assets"
|
||||
},
|
||||
{
|
||||
"text": "article周报",
|
||||
"href": "/weekly-reports"
|
||||
},
|
||||
{
|
||||
"text": "check_circle待办",
|
||||
"href": "/todos"
|
||||
},
|
||||
{
|
||||
"text": "campaign公告",
|
||||
"href": "/announcements"
|
||||
},
|
||||
{
|
||||
"text": "workspaces项目",
|
||||
"href": "/projects"
|
||||
},
|
||||
{
|
||||
"text": "shopping_cart采购申请",
|
||||
"href": "/purchase-requests"
|
||||
},
|
||||
{
|
||||
"text": "flight差旅申请",
|
||||
"href": "/travel-requests"
|
||||
},
|
||||
{
|
||||
"text": "receipt_long票据中心",
|
||||
"href": "/invoice-pool"
|
||||
}
|
||||
],
|
||||
"tables": [],
|
||||
"dialogs": [],
|
||||
"longUuidLike": [],
|
||||
"hasDeleteText": false,
|
||||
"hasReimbursementDelete": false,
|
||||
"hasInvoiceOptionalBinding": false,
|
||||
"verboseFlags": {
|
||||
"hasViewEditSubmit": false,
|
||||
"hasInvoiceWaitOcrCopy": false,
|
||||
"hasCurrentPageMetrics": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"errors": [
|
||||
"first detail link not visible"
|
||||
],
|
||||
"consoleMessages": []
|
||||
}
|
||||
|
之后 宽度: | 高度: | 大小: 182 KiB |
|
之后 宽度: | 高度: | 大小: 227 KiB |
|
之后 宽度: | 高度: | 大小: 110 KiB |
|
之后 宽度: | 高度: | 大小: 398 KiB |
|
之后 宽度: | 高度: | 大小: 448 KiB |
|
之后 宽度: | 高度: | 大小: 118 KiB |
|
之后 宽度: | 高度: | 大小: 108 KiB |
|
之后 宽度: | 高度: | 大小: 168 KiB |
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"generatedAt": "2026-05-06T13:39:43.542Z",
|
||||
"temporaryData": {
|
||||
"reimbursementId": "aacf9d44-0b31-4ca1-a846-198ea55f6d84",
|
||||
"formNumber": null,
|
||||
"itemId": "658be08d-3b30-4b20-b42f-0d277faab017",
|
||||
"expenseType": "机票"
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"id": "pc-reimbursements-list-with-draft",
|
||||
"url": "https://oa.hyinda.com/reimbursements?status=draft",
|
||||
"textStart": "海音达工作台\n审批 报销 团队\n今日工作台\n先处理待办和团队状态,再进入具体模块。\n主导航\n优先处理今日工作与高频协同。\nhome_app_logo\nPortal\ngroups\n团队\nevent_available\n请假\nrequest_quote\n报销\ninventory_2\n资产\narticle\n周报\ncheck_circle\n待办\ncampaign\n公告\nworkspaces\n项目\n备用模块\n默认收起,不干扰主路径\n按需展开\n主导航\n\n报销\n\n你好,Employee\nsearch\n搜索\nEmployee\n我的报销\n\n草稿、审批、支付。\n\n新建报销\n导出\n当前列表\n1\n共 1 条\n当前金额\n¥12.30\n当前页\n审批中\n0\n待推进\n已支付\n0\n仅自己\n草稿\n审批中\n已通过\n已支付\n已驳回\n已取消\n单号\t状态\t金额\t报销人\t日期\t操作\n\naacf9d44-0b31-4ca1-a846-198ea55f6d84\n\t草稿\t¥12.30\t\nE\nEmployee\n\t2026-05-06\t编辑\n第 1 / 1 页\n上一页\n下一页",
|
||||
"buttons": [
|
||||
"search搜索",
|
||||
"search",
|
||||
"Employee",
|
||||
"新建报销",
|
||||
"导出",
|
||||
"草稿",
|
||||
"审批中",
|
||||
"已通过",
|
||||
"已支付",
|
||||
"已驳回",
|
||||
"已取消",
|
||||
"上一页",
|
||||
"下一页"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"text": "home_app_logoPortal",
|
||||
"href": "/portal"
|
||||
},
|
||||
{
|
||||
"text": "groups团队",
|
||||
"href": "/team"
|
||||
},
|
||||
{
|
||||
"text": "event_available请假",
|
||||
"href": "/leave-requests"
|
||||
},
|
||||
{
|
||||
"text": "request_quote报销",
|
||||
"href": "/reimbursements"
|
||||
},
|
||||
{
|
||||
"text": "inventory_2资产",
|
||||
"href": "/assets"
|
||||
},
|
||||
{
|
||||
"text": "article周报",
|
||||
"href": "/weekly-reports"
|
||||
},
|
||||
{
|
||||
"text": "check_circle待办",
|
||||
"href": "/todos"
|
||||
},
|
||||
{
|
||||
"text": "campaign公告",
|
||||
"href": "/announcements"
|
||||
},
|
||||
{
|
||||
"text": "workspaces项目",
|
||||
"href": "/projects"
|
||||
},
|
||||
{
|
||||
"text": "shopping_cart采购申请",
|
||||
"href": "/purchase-requests"
|
||||
},
|
||||
{
|
||||
"text": "flight差旅申请",
|
||||
"href": "/travel-requests"
|
||||
},
|
||||
{
|
||||
"text": "receipt_long票据中心",
|
||||
"href": "/invoice-pool"
|
||||
},
|
||||
{
|
||||
"text": "继续编辑",
|
||||
"href": "/reimbursements/aacf9d44-0b31-4ca1-a846-198ea55f6d84"
|
||||
},
|
||||
{
|
||||
"text": "编辑",
|
||||
"href": "/reimbursements/aacf9d44-0b31-4ca1-a846-198ea55f6d84"
|
||||
}
|
||||
],
|
||||
"dialogs": [],
|
||||
"tables": [
|
||||
{
|
||||
"headers": [
|
||||
"单号",
|
||||
"状态",
|
||||
"金额",
|
||||
"报销人",
|
||||
"日期",
|
||||
"操作"
|
||||
],
|
||||
"firstRow": [
|
||||
"aacf9d44-0b31-4ca1-a846-198ea55f6d84",
|
||||
"草稿",
|
||||
"¥12.30",
|
||||
"EEmployee",
|
||||
"2026-05-06",
|
||||
"编辑"
|
||||
],
|
||||
"rowCount": 1
|
||||
}
|
||||
],
|
||||
"longUuidLike": [
|
||||
"aacf9d44-0b31-4ca1-a846-198ea55f6d84"
|
||||
],
|
||||
"flags": {
|
||||
"hasWholeDelete": false,
|
||||
"hasDetailDeleteOnly": false,
|
||||
"hasRawReimbursementId": false,
|
||||
"hasInvoiceOptionalBinding": false,
|
||||
"hasVerboseDetailSubtitle": false,
|
||||
"hasVerboseInvoiceCopy": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pc-reimbursement-detail-with-item",
|
||||
"url": "https://oa.hyinda.com/reimbursements/aacf9d44-0b31-4ca1-a846-198ea55f6d84",
|
||||
"textStart": "海音达工作台\n审批 报销 团队\n今日工作台\n先处理待办和团队状态,再进入具体模块。\n主导航\n优先处理今日工作与高频协同。\nhome_app_logo\nPortal\ngroups\n团队\nevent_available\n请假\nrequest_quote\n报销\ninventory_2\n资产\narticle\n周报\ncheck_circle\n待办\ncampaign\n公告\nworkspaces\n项目\n备用模块\n默认收起,不干扰主路径\n按需展开\n主导航\n\n报销详情\n\n你好,Employee\nsearch\n搜索\nEmployee\n报销详情 aacf9d44-0b31-4ca1-a846-198ea55f6d84\n\n查看、编辑、提交。\n\n草稿\n报销日期:2026-05-06\n报销人:Employee\n提交人:Employee\n金额:12.3\n打印 / PDF\n返回列表\n概览与关联\n当前状态\n草稿\n关联项目\n未关联\n明细数\n1\n关联票据\n0\n状态:draft\n报销ID:aacf9d44-0b31-4ca1-a846-198ea55f6d84\n报销日期:2026-05-06\n金额:12.3\n提交人:Employee\n报销人:Employee\n说明:R100 PC 报销体验自动化审查临时草稿\n关联项目\n\n报销可选挂到项目,用于项目执行面板汇总。\n\n不关联项目\n明细与票据\n报销明细\n\n明细变更会自动重算总金额。\n\n新增明细\n费用类型\t发生日期\t金额\t说明\t操作\n\n机票\ntravel_airfare\n\t2026-05-06\t\n12.3\n\t\nR100 临时明细\n\t\n编辑\n删除\n关联票据\n\n可在当前页面内直接绑定已有发票,或上传新发票后等待 OCR 完成。\n\n添加发票\n\n暂无关联票据\n\n风险与同步\n报销摘要\n报销编号:aacf9d44-0b31-4ca1-a846-198ea55f6d84\n报销人:Employee\n提交人:Employee\n当前审批:-\n明细数:1\n票据数:0\n关联项目:未关联\n可执行动作\n提交报销\n\n提交前需先绑定至少 1 张发票,可在“关联票据”区域添加。\n\n撤回报销\n\n撤回",
|
||||
"buttons": [
|
||||
"search搜索",
|
||||
"search",
|
||||
"Employee",
|
||||
"新增明细",
|
||||
"编辑",
|
||||
"删除",
|
||||
"添加发票",
|
||||
"提交报销",
|
||||
"撤回"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"text": "home_app_logoPortal",
|
||||
"href": "/portal"
|
||||
},
|
||||
{
|
||||
"text": "groups团队",
|
||||
"href": "/team"
|
||||
},
|
||||
{
|
||||
"text": "event_available请假",
|
||||
"href": "/leave-requests"
|
||||
},
|
||||
{
|
||||
"text": "request_quote报销",
|
||||
"href": "/reimbursements"
|
||||
},
|
||||
{
|
||||
"text": "inventory_2资产",
|
||||
"href": "/assets"
|
||||
},
|
||||
{
|
||||
"text": "article周报",
|
||||
"href": "/weekly-reports"
|
||||
},
|
||||
{
|
||||
"text": "check_circle待办",
|
||||
"href": "/todos"
|
||||
},
|
||||
{
|
||||
"text": "campaign公告",
|
||||
"href": "/announcements"
|
||||
},
|
||||
{
|
||||
"text": "workspaces项目",
|
||||
"href": "/projects"
|
||||
},
|
||||
{
|
||||
"text": "shopping_cart采购申请",
|
||||
"href": "/purchase-requests"
|
||||
},
|
||||
{
|
||||
"text": "flight差旅申请",
|
||||
"href": "/travel-requests"
|
||||
},
|
||||
{
|
||||
"text": "receipt_long票据中心",
|
||||
"href": "/invoice-pool"
|
||||
},
|
||||
{
|
||||
"text": "打印 / PDF",
|
||||
"href": "/reimbursements/aacf9d44-0b31-4ca1-a846-198ea55f6d84/print"
|
||||
},
|
||||
{
|
||||
"text": "返回列表",
|
||||
"href": "/reimbursements"
|
||||
}
|
||||
],
|
||||
"dialogs": [],
|
||||
"tables": [
|
||||
{
|
||||
"headers": [
|
||||
"费用类型",
|
||||
"发生日期",
|
||||
"金额",
|
||||
"说明",
|
||||
"操作"
|
||||
],
|
||||
"firstRow": [
|
||||
"机票travel_airfare",
|
||||
"2026-05-06",
|
||||
"12.3",
|
||||
"R100 临时明细",
|
||||
"编辑删除"
|
||||
],
|
||||
"rowCount": 1
|
||||
}
|
||||
],
|
||||
"longUuidLike": [
|
||||
"aacf9d44-0b31-4ca1-a846-198ea55f6d84"
|
||||
],
|
||||
"flags": {
|
||||
"hasWholeDelete": false,
|
||||
"hasDetailDeleteOnly": false,
|
||||
"hasRawReimbursementId": true,
|
||||
"hasInvoiceOptionalBinding": false,
|
||||
"hasVerboseDetailSubtitle": true,
|
||||
"hasVerboseInvoiceCopy": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pc-reimbursement-invoice-dialog-with-item",
|
||||
"url": "https://oa.hyinda.com/reimbursements/aacf9d44-0b31-4ca1-a846-198ea55f6d84",
|
||||
"textStart": "海音达工作台\n审批 报销 团队\n今日工作台\n先处理待办和团队状态,再进入具体模块。\n主导航\n优先处理今日工作与高频协同。\nhome_app_logo\nPortal\ngroups\n团队\nevent_available\n请假\nrequest_quote\n报销\ninventory_2\n资产\narticle\n周报\ncheck_circle\n待办\ncampaign\n公告\nworkspaces\n项目\n备用模块\n默认收起,不干扰主路径\n按需展开\n主导航\n\n报销详情\n\n你好,Employee\nsearch\n搜索\nEmployee\n报销详情 aacf9d44-0b31-4ca1-a846-198ea55f6d84\n\n查看、编辑、提交。\n\n草稿\n报销日期:2026-05-06\n报销人:Employee\n提交人:Employee\n金额:12.3\n打印 / PDF\n返回列表\n概览与关联\n当前状态\n草稿\n关联项目\n未关联\n明细数\n1\n关联票据\n0\n状态:draft\n报销ID:aacf9d44-0b31-4ca1-a846-198ea55f6d84\n报销日期:2026-05-06\n金额:12.3\n提交人:Employee\n报销人:Employee\n说明:R100 PC 报销体验自动化审查临时草稿\n关联项目\n\n报销可选挂到项目,用于项目执行面板汇总。\n\n不关联项目\n明细与票据\n报销明细\n\n明细变更会自动重算总金额。\n\n新增明细\n费用类型\t发生日期\t金额\t说明\t操作\n\n机票\ntravel_airfare\n\t2026-05-06\t\n12.3\n\t\nR100 临时明细\n\t\n编辑\n删除\n关联票据\n\n可在当前页面内直接绑定已有发票,或上传新发票后等待 OCR 完成。\n\n添加发票\n\n暂无关联票据\n\n风险与同步\n报销摘要\n报销编号:aacf9d44-0b31-4ca1-a846-198ea55f6d84\n报销人:Employee\n提交人:Employee\n当前审批:-\n明细数:1\n票据数:0\n关联项目:未关联\n可执行动作\n提交报销\n\n提交前需先绑定至少 1 张发票,可在“关联票据”区域添加。\n\n撤回报销\n\n撤回\n添加发票\n\n选择当前报销人可绑定的可用发票,或上传新发票等待 OCR 完成后直接绑定。\n\n刷新列表\n发票 26112000000938423851-26112000000938423851\n北京悦海堂餐饮管理有限公司 · 2026-03-11 · 金额 ¥1683\n原始文件:北京悦海堂餐饮管理有限公司_发票金额1683.00元.pdf · 上传时间 2026-05-05 15:58:28\n绑定到明细(可选)\n不绑定具体明细\n机票 · R100 临时明细\n发票 26112000000938423851-26112000000938423851\n北京悦海堂餐饮管理有限公司 · 2026-03-11 · 金额 ¥1683\n原始文件:北京悦海堂餐饮管理有限公司_发票金额1683.00元.pdf · 上传时间 2026-05-05 15:55:32\n绑定到明细(可选)\n不绑定具体明细\n机票 · R100 临时明细\n发票 26112000000938423851-26112000000938423851\n北京悦海堂餐饮管理有限公司 · 2026-03-11 · 金额 ¥1683\n原始文件:北京悦海堂餐饮管理有限公司_发票金额1683.00元.pdf · 上传时间 2026-05-05 14:37:01\n绑定到明细(可选)\n不绑定具体明细\n机票 · R100 临时明细\n已选 0 张发票\n绑定选中发票\n或者上传新发票\n\n上传后会自动使用当前报销人的 owner 信息,并等待 OCR 识别完成。\n\n选择发票文件\n上传并等待 OCR",
|
||||
"buttons": [
|
||||
"search搜索",
|
||||
"search",
|
||||
"Employee",
|
||||
"新增明细",
|
||||
"编辑",
|
||||
"删除",
|
||||
"添加发票",
|
||||
"提交报销",
|
||||
"撤回",
|
||||
"刷新列表",
|
||||
"绑定选中发票",
|
||||
"上传并等待 OCR"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"text": "home_app_logoPortal",
|
||||
"href": "/portal"
|
||||
},
|
||||
{
|
||||
"text": "groups团队",
|
||||
"href": "/team"
|
||||
},
|
||||
{
|
||||
"text": "event_available请假",
|
||||
"href": "/leave-requests"
|
||||
},
|
||||
{
|
||||
"text": "request_quote报销",
|
||||
"href": "/reimbursements"
|
||||
},
|
||||
{
|
||||
"text": "inventory_2资产",
|
||||
"href": "/assets"
|
||||
},
|
||||
{
|
||||
"text": "article周报",
|
||||
"href": "/weekly-reports"
|
||||
},
|
||||
{
|
||||
"text": "check_circle待办",
|
||||
"href": "/todos"
|
||||
},
|
||||
{
|
||||
"text": "campaign公告",
|
||||
"href": "/announcements"
|
||||
},
|
||||
{
|
||||
"text": "workspaces项目",
|
||||
"href": "/projects"
|
||||
},
|
||||
{
|
||||
"text": "shopping_cart采购申请",
|
||||
"href": "/purchase-requests"
|
||||
},
|
||||
{
|
||||
"text": "flight差旅申请",
|
||||
"href": "/travel-requests"
|
||||
},
|
||||
{
|
||||
"text": "receipt_long票据中心",
|
||||
"href": "/invoice-pool"
|
||||
},
|
||||
{
|
||||
"text": "打印 / PDF",
|
||||
"href": "/reimbursements/aacf9d44-0b31-4ca1-a846-198ea55f6d84/print"
|
||||
},
|
||||
{
|
||||
"text": "返回列表",
|
||||
"href": "/reimbursements"
|
||||
}
|
||||
],
|
||||
"dialogs": [
|
||||
{
|
||||
"text": "添加发票选择当前报销人可绑定的可用发票,或上传新发票等待 OCR 完成后直接绑定。刷新列表发票 26112000000938423851-26112000000938423851北京悦海堂餐饮管理有限公司 · 2026-03-11 · 金额 ¥1683原始文件:北京悦海堂餐饮管理有限公司_发票金额1683.00元.pdf · 上传时间 2026-05-05 15:58:28绑定到明细(可选)不绑定具体明细机票 · R100 临时明细发票 26112000000938423851-26112000000938423851北京悦海堂餐饮管理有限公司 · 2026-03-11 · 金额 ¥1683原始文件:北京悦海堂餐饮管理有限公司_发票金额1683.00元.pdf · 上传时间 2026-05-05 15:55:32绑定到明细(可选)不绑定具体明细机票 · R100 临时明细发票 26112000000938423851-26112000000938423851北京悦海堂餐饮管理有限公司 · 2026-03-11 · 金额 ¥1683原始文件:北京悦海堂餐饮管理有限公司_发票金额1683.00元.pdf · 上传时间 2026-05-05 14:37:01绑定到明细(可选)不绑定具体明细机票 · R100 临时明细已选 0 张发票绑定选中发票或者上传新发票上传后会自动使用当前报销人的 owner 信息,并等待 OCR 识别完成。选择发票文件上传并等待 OCR",
|
||||
"buttons": [
|
||||
"刷新列表",
|
||||
"绑定选中发票",
|
||||
"上传并等待 OCR"
|
||||
],
|
||||
"selects": [
|
||||
{
|
||||
"value": "",
|
||||
"selectedText": "不绑定具体明细",
|
||||
"options": [
|
||||
{
|
||||
"value": "",
|
||||
"text": "不绑定具体明细"
|
||||
},
|
||||
{
|
||||
"value": "658be08d-3b30-4b20-b42f-0d277faab017",
|
||||
"text": "机票 · R100 临时明细"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"value": "",
|
||||
"selectedText": "不绑定具体明细",
|
||||
"options": [
|
||||
{
|
||||
"value": "",
|
||||
"text": "不绑定具体明细"
|
||||
},
|
||||
{
|
||||
"value": "658be08d-3b30-4b20-b42f-0d277faab017",
|
||||
"text": "机票 · R100 临时明细"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"value": "",
|
||||
"selectedText": "不绑定具体明细",
|
||||
"options": [
|
||||
{
|
||||
"value": "",
|
||||
"text": "不绑定具体明细"
|
||||
},
|
||||
{
|
||||
"value": "658be08d-3b30-4b20-b42f-0d277faab017",
|
||||
"text": "机票 · R100 临时明细"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tables": [
|
||||
{
|
||||
"headers": [
|
||||
"费用类型",
|
||||
"发生日期",
|
||||
"金额",
|
||||
"说明",
|
||||
"操作"
|
||||
],
|
||||
"firstRow": [
|
||||
"机票travel_airfare",
|
||||
"2026-05-06",
|
||||
"12.3",
|
||||
"R100 临时明细",
|
||||
"编辑删除"
|
||||
],
|
||||
"rowCount": 1
|
||||
}
|
||||
],
|
||||
"longUuidLike": [
|
||||
"aacf9d44-0b31-4ca1-a846-198ea55f6d84"
|
||||
],
|
||||
"flags": {
|
||||
"hasWholeDelete": false,
|
||||
"hasDetailDeleteOnly": false,
|
||||
"hasRawReimbursementId": true,
|
||||
"hasInvoiceOptionalBinding": true,
|
||||
"hasVerboseDetailSubtitle": true,
|
||||
"hasVerboseInvoiceCopy": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"reimbursementId": "aacf9d44-0b31-4ca1-a846-198ea55f6d84",
|
||||
"outcome": "deleted",
|
||||
"unboundInvoiceCount": 0
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
|
之后 宽度: | 高度: | 大小: 708 KiB |
|
之后 宽度: | 高度: | 大小: 128 KiB |
|
之后 宽度: | 高度: | 大小: 437 KiB |
|
之后 宽度: | 高度: | 大小: 128 KiB |
|
之后 宽度: | 高度: | 大小: 272 KiB |
|
之后 宽度: | 高度: | 大小: 300 KiB |
|
之后 宽度: | 高度: | 大小: 140 KiB |
@@ -65,7 +65,9 @@ export function MobileBottomNav() {
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"touch-target flex flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-xs font-medium transition-colors",
|
||||
isActive ? "bg-zinc-900 text-white shadow-sm" : "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-900",
|
||||
isActive
|
||||
? "bg-primary-50 text-primary ring-1 ring-primary-100"
|
||||
: "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-900",
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -77,7 +79,7 @@ export function MobileBottomNav() {
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -right-3 -top-2 min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none",
|
||||
isActive ? "bg-white text-zinc-900" : "bg-red-500 text-white",
|
||||
isActive ? "bg-primary text-white" : "bg-red-500 text-white",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
|
||||
@@ -26,14 +26,12 @@ export function AppShellLayout() {
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen overflow-hidden bg-[var(--color-bg-canvas)]">
|
||||
<Sidebar />
|
||||
{isMobileRoute ? null : <Sidebar />}
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col lg:ml-72">
|
||||
<div className={isMobileRoute ? "hidden md:block" : ""}>
|
||||
<Topbar />
|
||||
</div>
|
||||
<main className={`flex min-w-0 flex-1 flex-col ${isMobileRoute ? "" : "lg:ml-72"}`}>
|
||||
{isMobileRoute ? null : <Topbar />}
|
||||
|
||||
<div className={`flex-1 px-4 py-5 sm:px-7 sm:py-7 lg:px-8 ${isMobileRoute ? "pb-24 pt-4 md:pb-8" : ""}`}>
|
||||
<div className={`flex-1 px-4 py-5 ${isMobileRoute ? "pb-24 pt-4 sm:px-5 sm:py-5" : "sm:px-7 sm:py-7 lg:px-8"}`}>
|
||||
<div className="layout-container max-w-none">
|
||||
<ErrorBoundary>
|
||||
<Outlet />
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createLeaveRequest,
|
||||
fetchLeaveBalances,
|
||||
fetchLeaveRequests,
|
||||
fetchLeaveTypes,
|
||||
patchLeaveRequest,
|
||||
submitLeaveRequest,
|
||||
type LeaveRequestItem,
|
||||
type LeaveRequestStatus,
|
||||
type LeaveSession,
|
||||
} from "../api/leave";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
|
||||
type LeaveStatusFilter = "all" | LeaveRequestStatus;
|
||||
|
||||
const STATUS_TABS: Array<{ value: LeaveStatusFilter; label: string }> = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "reviewing", label: "审批中" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<LeaveRequestStatus, string> = {
|
||||
draft: "草稿",
|
||||
reviewing: "审批中",
|
||||
approved: "已通过",
|
||||
rejected: "已驳回",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
function todayDate() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatSession(value: LeaveSession) {
|
||||
return value === "am" ? "上午" : "下午";
|
||||
}
|
||||
|
||||
function formatLeaveRange(item: LeaveRequestItem) {
|
||||
return `${item.startDate} ${formatSession(item.startSession)} - ${item.endDate} ${formatSession(item.endSession)}`;
|
||||
}
|
||||
|
||||
function LeaveRecordCard({
|
||||
item,
|
||||
isSaving,
|
||||
onEdit,
|
||||
onSubmit,
|
||||
}: {
|
||||
item: LeaveRequestItem;
|
||||
isSaving: boolean;
|
||||
onEdit: (item: LeaveRequestItem) => void;
|
||||
onSubmit: (item: LeaveRequestItem) => void;
|
||||
}) {
|
||||
const canEdit = item.status === "draft" || item.status === "rejected";
|
||||
const canSubmit = item.status === "draft" || item.status === "rejected";
|
||||
|
||||
return (
|
||||
<article className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-leave-record-card">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{item.leaveType?.name ?? "请假"}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatLeaveRange(item)}</div>
|
||||
</div>
|
||||
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">天数</div>
|
||||
<div className="mt-1 text-lg font-semibold text-zinc-900">{item.requestedDays}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">附件</div>
|
||||
<div className="mt-1 truncate text-zinc-900">{item.attachment?.originalName ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.reason ? <p className="mt-3 line-clamp-2 text-sm text-zinc-500">{item.reason}</p> : null}
|
||||
|
||||
{canEdit || canSubmit ? (
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
{canEdit ? (
|
||||
<Button variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
{canSubmit ? (
|
||||
<Button type="button" disabled={isSaving} onClick={() => onSubmit(item)}>
|
||||
提交
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileLeavePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const canCreateLeave = hasPermission(permissions, "leave.create");
|
||||
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>("all");
|
||||
const [editingItem, setEditingItem] = useState<LeaveRequestItem | null>(null);
|
||||
const [leaveTypeId, setLeaveTypeId] = useState("");
|
||||
const [startDate, setStartDate] = useState(todayDate);
|
||||
const [endDate, setEndDate] = useState(todayDate);
|
||||
const [startSession, setStartSession] = useState<LeaveSession>("am");
|
||||
const [endSession, setEndSession] = useState<LeaveSession>("pm");
|
||||
const [reason, setReason] = useState("");
|
||||
const [attachmentFile, setAttachmentFile] = useState<File | null>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const leaveTypesQuery = useQuery({
|
||||
queryKey: ["mobile", "leave", "types"],
|
||||
queryFn: () => fetchLeaveTypes({ enabled: true }),
|
||||
});
|
||||
|
||||
const balancesQuery = useQuery({
|
||||
queryKey: ["mobile", "leave", "balances"],
|
||||
queryFn: () => fetchLeaveBalances(),
|
||||
});
|
||||
|
||||
const requestsQuery = useQuery({
|
||||
queryKey: ["mobile", "leave", "requests", statusFilter],
|
||||
queryFn: () =>
|
||||
fetchLeaveRequests({
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
scope: "own",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
});
|
||||
|
||||
const invalidateLeaveQueries = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["mobile", "leave"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["leave"] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingItem(null);
|
||||
setLeaveTypeId("");
|
||||
setStartDate(todayDate());
|
||||
setEndDate(todayDate());
|
||||
setStartSession("am");
|
||||
setEndSession("pm");
|
||||
setReason("");
|
||||
setAttachmentFile(null);
|
||||
if (attachmentInputRef.current) {
|
||||
attachmentInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createLeaveRequest({
|
||||
leaveTypeId,
|
||||
startDate,
|
||||
endDate,
|
||||
startSession,
|
||||
endSession,
|
||||
reason,
|
||||
file: attachmentFile,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success("请假草稿已创建");
|
||||
resetForm();
|
||||
await invalidateLeaveQueries();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!editingItem) {
|
||||
throw new Error("未选择请假单");
|
||||
}
|
||||
return patchLeaveRequest(editingItem.id, {
|
||||
leaveTypeId,
|
||||
startDate,
|
||||
endDate,
|
||||
startSession,
|
||||
endSession,
|
||||
reason,
|
||||
file: attachmentFile,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success("请假单已保存");
|
||||
resetForm();
|
||||
await invalidateLeaveQueries();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (item: LeaveRequestItem) => submitLeaveRequest(item.id),
|
||||
onSuccess: async () => {
|
||||
toast.success("请假单已提交");
|
||||
await invalidateLeaveQueries();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const leaveTypes = leaveTypesQuery.data?.items ?? [];
|
||||
const records = requestsQuery.data?.items ?? [];
|
||||
const balances = balancesQuery.data?.items ?? [];
|
||||
const balanceSummary = useMemo(
|
||||
() =>
|
||||
balances.reduce(
|
||||
(summary, item) => ({
|
||||
available: summary.available + item.available,
|
||||
reserved: summary.reserved + item.reserved,
|
||||
used: summary.used + item.used,
|
||||
}),
|
||||
{ available: 0, reserved: 0, used: 0 },
|
||||
),
|
||||
[balances],
|
||||
);
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending || submitMutation.isPending;
|
||||
|
||||
const startEditing = (item: LeaveRequestItem) => {
|
||||
setEditingItem(item);
|
||||
setLeaveTypeId(item.leaveType?.id ?? "");
|
||||
setStartDate(item.startDate);
|
||||
setEndDate(item.endDate);
|
||||
setStartSession(item.startSession);
|
||||
setEndSession(item.endSession);
|
||||
setReason(item.reason ?? "");
|
||||
setAttachmentFile(null);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<PageScaffold className="space-y-4">
|
||||
<PageHeader title="请假" />
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-leave-summary">
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">可用</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.available.toFixed(1)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">冻结</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.reserved.toFixed(1)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">已用</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.used.toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-leave-form">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">{editingItem ? "编辑请假" : "新建请假"}</h3>
|
||||
{editingItem ? (
|
||||
<Button variant="secondary" size="sm" type="button" onClick={resetForm}>
|
||||
取消
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>假期类型</span>
|
||||
<select className="select-std" value={leaveTypeId} onChange={(event) => setLeaveTypeId(event.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{leaveTypes.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>开始日期</span>
|
||||
<input className="input-std" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
|
||||
</label>
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>结束日期</span>
|
||||
<input className="input-std" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>开始时段</span>
|
||||
<select className="select-std" value={startSession} onChange={(event) => setStartSession(event.target.value as LeaveSession)}>
|
||||
<option value="am">上午</option>
|
||||
<option value="pm">下午</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>结束时段</span>
|
||||
<select className="select-std" value={endSession} onChange={(event) => setEndSession(event.target.value as LeaveSession)}>
|
||||
<option value="am">上午</option>
|
||||
<option value="pm">下午</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>说明</span>
|
||||
<textarea
|
||||
className="input-std h-24 resize-none"
|
||||
placeholder="请输入请假原因"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>附件</span>
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
className="hidden"
|
||||
type="file"
|
||||
onChange={(event) => setAttachmentFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
>
|
||||
选择附件
|
||||
</Button>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-zinc-500" data-testid="mobile-leave-attachment-name">
|
||||
{attachmentFile?.name ?? "未选择文件"}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
disabled={!canCreateLeave || isSaving || !leaveTypeId || !startDate || !endDate}
|
||||
onClick={() => {
|
||||
if (editingItem) {
|
||||
updateMutation.mutate();
|
||||
return;
|
||||
}
|
||||
createMutation.mutate();
|
||||
}}
|
||||
data-testid="mobile-leave-save"
|
||||
>
|
||||
{editingItem ? "保存修改" : "创建草稿"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-leave-balances">
|
||||
<h3 className="text-base font-semibold text-zinc-900">额度</h3>
|
||||
<div className="mt-3 space-y-2">
|
||||
{balancesQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
|
||||
{!balancesQuery.isLoading && !balances.length ? <div className="text-sm text-zinc-500">暂无额度</div> : null}
|
||||
{balances.slice(0, 3).map((item) => (
|
||||
<div key={item.leaveType.id} className="rounded-lg border border-zinc-200 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{item.leaveType.name}</div>
|
||||
<span className="badge-success">启用</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">可用</div>
|
||||
<div className="font-semibold text-zinc-900">{item.available}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">冻结</div>
|
||||
<div className="font-semibold text-zinc-900">{item.reserved}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">已用</div>
|
||||
<div className="font-semibold text-zinc-900">{item.used}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-leave-records">
|
||||
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
|
||||
<Tabs value={statusFilter} onValueChange={(value) => setStatusFilter(value as LeaveStatusFilter)}>
|
||||
<TabsList className="mt-3 grid h-auto w-full grid-cols-4">
|
||||
{STATUS_TABS.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-leave-tab-${item.value}`}>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{requestsQuery.isLoading ? <div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">加载中...</div> : null}
|
||||
{requestsQuery.isError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||
{resolveErrorMessage(requestsQuery.error)}
|
||||
</div>
|
||||
) : null}
|
||||
{!requestsQuery.isLoading && !requestsQuery.isError && !records.length ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">暂无请假记录</div>
|
||||
) : null}
|
||||
{records.map((item) => (
|
||||
<LeaveRecordCard
|
||||
item={item}
|
||||
isSaving={isSaving}
|
||||
key={item.id}
|
||||
onEdit={startEditing}
|
||||
onSubmit={(nextItem) => submitMutation.mutate(nextItem)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileLeavePage;
|
||||
@@ -79,14 +79,11 @@ export function MobileMePage() {
|
||||
|
||||
return (
|
||||
<PageScaffold className="space-y-4">
|
||||
<PageHeader
|
||||
title="我的"
|
||||
subtitle="个人信息、常用入口和低频管理操作。"
|
||||
/>
|
||||
<PageHeader title="我的" />
|
||||
|
||||
<SectionPanel title="个人信息" description="头像与部门信息从完整员工档案读取。">
|
||||
<SectionPanel title="个人信息">
|
||||
<div className="flex items-center gap-4">
|
||||
<EmployeeAvatar name={me?.name ?? "员工"} src={profile?.avatarUrl} size="xl" />
|
||||
<EmployeeAvatar name={me?.name ?? "员工"} src={profile?.avatarUrl} size="lg" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="text-lg font-semibold text-zinc-900">{me?.name ?? "-"}</div>
|
||||
<div className="text-sm text-zinc-500">{departmentName}</div>
|
||||
@@ -102,28 +99,28 @@ export function MobileMePage() {
|
||||
) : null}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="账号与绑定" description="个人设置里可维护企业微信绑定、通知偏好和账号资料。">
|
||||
<SectionPanel title="账号与绑定">
|
||||
<div className="grid gap-3">
|
||||
<MobileActionLink to="/settings/profile" label="个人设置" description="绑定企业微信、维护账号与通知偏好" icon="manage_accounts" />
|
||||
<MobileActionLink to="/settings/profile" label="个人设置" description="企业微信、账号与通知" icon="manage_accounts" />
|
||||
{me?.id ? (
|
||||
<MobileActionLink to={`/people/${me.id}`} label="完整员工档案" description="办公地点、紧急联系人和完整档案" icon="badge" />
|
||||
<MobileActionLink to={`/people/${me.id}`} label="员工档案" description="办公地点和紧急联系人" icon="badge" />
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="更多入口" description="低频模块放在这里,避免干扰移动首页。">
|
||||
<SectionPanel title="更多入口">
|
||||
<div className="grid gap-3" data-testid="mobile-more-links">
|
||||
<MobileActionLink to="/notifications" label="通知中心" description="查看全部站内通知" icon="notifications" />
|
||||
<MobileActionLink to="/leave-requests" label="请假" description="提交或查看请假申请" icon="event_available" />
|
||||
<MobileActionLink to="/reimbursements" label="报销" description="提交报销和查看报销进度" icon="request_quote" />
|
||||
{canAccessApprovals ? <MobileActionLink to="/mobile/approvals" label="审批" description="处理移动审批待办" icon="fact_check" /> : null}
|
||||
{canAccessAssets ? <MobileActionLink to="/assets" label="资产" description="查看资产台账和盘点入口" icon="inventory_2" /> : null}
|
||||
{canAccessProjects ? <MobileActionLink to="/projects" label="项目" description="查看项目、风险和里程碑" icon="workspaces" /> : null}
|
||||
<MobileActionLink to="/mobile/leave" label="请假" description="申请和记录" icon="event_available" />
|
||||
<MobileActionLink to="/mobile/reimbursements" label="报销" description="草稿和进度" icon="request_quote" />
|
||||
{canAccessApprovals ? <MobileActionLink to="/mobile/approvals" label="审批" description="待办处理" icon="fact_check" /> : null}
|
||||
{canAccessAssets ? <MobileActionLink to="/assets" label="资产" description="台账和盘点" icon="inventory_2" /> : null}
|
||||
{canAccessProjects ? <MobileActionLink to="/projects" label="项目" description="项目和里程碑" icon="workspaces" /> : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
{canAccessAdmin ? (
|
||||
<SectionPanel title="管理入口" description="管理员和负责人低频使用。">
|
||||
<SectionPanel title="管理入口">
|
||||
<div className="grid gap-3">
|
||||
<MobileActionLink to="/people" label="人员管理" description="用户、角色和员工资料" icon="group" />
|
||||
<MobileActionLink to="/organization" label="组织架构" description="部门和汇报关系" icon="account_tree" />
|
||||
@@ -132,7 +129,7 @@ export function MobileMePage() {
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
<SectionPanel title="外部通知推送" description="关闭后仅停止外部渠道推送,站内通知仍保留。">
|
||||
<SectionPanel title="外部通知推送">
|
||||
<label className="flex items-center gap-3 text-sm text-zinc-800">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -143,9 +140,6 @@ export function MobileMePage() {
|
||||
/>
|
||||
接收外部渠道推送
|
||||
</label>
|
||||
<p className="mt-3 text-sm leading-6 text-zinc-500">
|
||||
适用于审批待办、报销付款和资产流转提醒。关闭后不会影响站内通知或系统登录。
|
||||
</p>
|
||||
</SectionPanel>
|
||||
|
||||
<Button type="button" variant="secondary" className="w-full border-red-200 text-red-600 hover:bg-red-50" onClick={() => void logout()}>
|
||||
|
||||
@@ -130,7 +130,7 @@ function ActionTile({ action }: { action: MobileAction }) {
|
||||
to={action.to}
|
||||
className={
|
||||
isPrimary
|
||||
? "flex min-h-24 flex-col justify-between rounded-lg bg-zinc-900 p-3 text-white shadow-sm"
|
||||
? "flex min-h-24 flex-col justify-between rounded-lg border border-primary-100 bg-primary-50 p-3 text-primary shadow-sm"
|
||||
: isWarning
|
||||
? "flex min-h-24 flex-col justify-between rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-900"
|
||||
: "flex min-h-24 flex-col justify-between rounded-lg border border-zinc-200 bg-white p-3 text-zinc-900 shadow-sm"
|
||||
@@ -141,7 +141,7 @@ function ActionTile({ action }: { action: MobileAction }) {
|
||||
<span className="text-sm font-semibold">{action.label}</span>
|
||||
<span className="material-symbols-outlined text-[20px]">{action.icon}</span>
|
||||
</div>
|
||||
<div className={isPrimary ? "text-xs text-white/75" : "text-xs text-zinc-500"}>{action.meta}</div>
|
||||
<div className={isPrimary ? "text-xs text-primary/70" : "text-xs text-zinc-500"}>{action.meta}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -234,9 +234,9 @@ export function MobilePortalPage() {
|
||||
? {
|
||||
key: "reimbursements",
|
||||
label: "报销",
|
||||
meta: "新建和查进度",
|
||||
meta: "新建与进度",
|
||||
icon: "request_quote",
|
||||
to: "/reimbursements",
|
||||
to: "/mobile/reimbursements",
|
||||
}
|
||||
: null,
|
||||
canAccessLeave
|
||||
@@ -245,7 +245,7 @@ export function MobilePortalPage() {
|
||||
label: "请假",
|
||||
meta: "提交申请",
|
||||
icon: "event_available",
|
||||
to: "/leave-requests",
|
||||
to: "/mobile/leave",
|
||||
}
|
||||
: null,
|
||||
{
|
||||
@@ -263,10 +263,8 @@ export function MobilePortalPage() {
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-today-focus">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-base font-semibold text-zinc-900">先处理这几件事</div>
|
||||
<span className={pendingApprovalCount > 0 ? "badge-warning" : "badge-success"}>
|
||||
{pendingApprovalCount > 0 ? `${pendingApprovalCount} 待批` : "清爽"}
|
||||
</span>
|
||||
<div className="text-base font-semibold text-zinc-900">待处理</div>
|
||||
{pendingApprovalCount > 0 ? <span className="badge-warning">{pendingApprovalCount} 待批</span> : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{actions.map((action) => (
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createReimbursement,
|
||||
fetchReimbursements,
|
||||
type ReimbursementListItem,
|
||||
type ReimbursementStatus,
|
||||
} from "../api/reimbursements";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
|
||||
const STATUS_TABS: Array<{ value: ReimbursementStatus; label: string }> = [
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "reviewing", label: "审批中" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "paid", label: "已支付" },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<ReimbursementStatus, string> = {
|
||||
draft: "草稿",
|
||||
reviewing: "审批中",
|
||||
approved: "已通过",
|
||||
paid: "已支付",
|
||||
rejected: "已驳回",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
function formatCurrency(value: string | null | undefined) {
|
||||
const numberValue = Number(value ?? 0);
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
return "¥0.00";
|
||||
}
|
||||
return new Intl.NumberFormat("zh-CN", {
|
||||
style: "currency",
|
||||
currency: "CNY",
|
||||
}).format(numberValue);
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
return value || "未填写日期";
|
||||
}
|
||||
|
||||
function ReimbursementCard({ item }: { item: ReimbursementListItem }) {
|
||||
const actionLabel = item.status === "draft" || item.status === "rejected" ? "继续编辑" : "查看详情";
|
||||
|
||||
return (
|
||||
<article className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-card">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.reimbursementDate)}</div>
|
||||
</div>
|
||||
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">金额</div>
|
||||
<div className="mt-1 text-lg font-semibold text-zinc-900">{formatCurrency(item.totalAmount)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">报销人</div>
|
||||
<div className="mt-1 truncate text-zinc-900">{item.owner?.name ?? item.submitter?.name ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.description ? <p className="mt-3 line-clamp-2 text-sm text-zinc-500">{item.description}</p> : null}
|
||||
|
||||
<Button asChild className="mt-4 w-full" type="button">
|
||||
<Link to={`/reimbursements/${item.id}`}>{actionLabel}</Link>
|
||||
</Button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileReimbursementsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const [status, setStatus] = useState<ReimbursementStatus>("draft");
|
||||
const canCreateExpense = hasPermission(permissions, "expense.create");
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["mobile", "reimbursements", status],
|
||||
queryFn: () => fetchReimbursements({ status, page: 1, pageSize: 20, scope: "own" }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createReimbursement(),
|
||||
onSuccess: async (response) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||
navigate(`/reimbursements/${response.id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const total = listQuery.data?.total ?? 0;
|
||||
const totalAmount = useMemo(
|
||||
() => items.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageScaffold className="space-y-4">
|
||||
<PageHeader
|
||||
title="报销"
|
||||
actions={
|
||||
canCreateExpense ? (
|
||||
<Button
|
||||
data-testid="mobile-reimbursements-create"
|
||||
disabled={createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">当前列表</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{total}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">当前金额</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{formatCurrency(String(totalAmount))}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursements-list">
|
||||
<Tabs value={status} onValueChange={(value) => setStatus(value as ReimbursementStatus)}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-4">
|
||||
{STATUS_TABS.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-reimbursements-tab-${item.value}`}>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{listQuery.isLoading ? <div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">加载中...</div> : null}
|
||||
{listQuery.isError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||
{resolveErrorMessage(listQuery.error)}
|
||||
</div>
|
||||
) : null}
|
||||
{!listQuery.isLoading && !listQuery.isError && !items.length ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">暂无报销记录</div>
|
||||
) : null}
|
||||
{items.map((item) => (
|
||||
<ReimbursementCard item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileReimbursementsPage;
|
||||
@@ -31,8 +31,10 @@ import {
|
||||
MobileApprovalsPage,
|
||||
MobileAssetScanPage,
|
||||
MobileCapturePage,
|
||||
MobileLeavePage,
|
||||
MobileMePage,
|
||||
MobilePortalPage,
|
||||
MobileReimbursementsPage,
|
||||
NotificationChannelsPage,
|
||||
NotificationsPage,
|
||||
OrganizationPage,
|
||||
@@ -309,6 +311,24 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/reimbursements"
|
||||
element={
|
||||
<RequirePermission anyOf={["expense.view_own", "expense.view_all"]}>
|
||||
{withRouteFallback(<MobileReimbursementsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/leave"
|
||||
element={
|
||||
<RequirePermission anyOf={["leave.view_own", "leave.view_all"]}>
|
||||
{withRouteFallback(<MobileLeavePage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/assets/scan"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { MobileLeavePage as default } from "../../pages/MobileLeavePage";
|
||||
@@ -0,0 +1 @@
|
||||
export { MobileReimbursementsPage as default } from "../../pages/MobileReimbursementsPage";
|
||||
@@ -18,8 +18,10 @@ export const MasterDataPage = lazy(() => import("./MasterDataPage"));
|
||||
export const MobileApprovalsPage = lazy(() => import("./MobileApprovalsPage"));
|
||||
export const MobileAssetScanPage = lazy(() => import("./MobileAssetScanPage"));
|
||||
export const MobileCapturePage = lazy(() => import("./MobileCapturePage"));
|
||||
export const MobileLeavePage = lazy(() => import("./MobileLeavePage"));
|
||||
export const MobileMePage = lazy(() => import("./MobileMePage"));
|
||||
export const MobilePortalPage = lazy(() => import("./MobilePortalPage"));
|
||||
export const MobileReimbursementsPage = lazy(() => import("./MobileReimbursementsPage"));
|
||||
export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage"));
|
||||
export const NotificationsPage = lazy(() => import("./NotificationsPage"));
|
||||
export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { expect, test, type Page, type Route, type TestInfo } from "@playwright/test";
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return { requestId: "mock-r101-mobile", data };
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope(data)),
|
||||
});
|
||||
}
|
||||
|
||||
const me = {
|
||||
id: "employee-r101",
|
||||
employeeNo: "E101",
|
||||
name: "Employee",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-r101",
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: ["employee"],
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: true,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 50,
|
||||
},
|
||||
wecomBinding: null,
|
||||
};
|
||||
|
||||
const leaveType = {
|
||||
id: "leave-type-annual",
|
||||
code: "annual",
|
||||
name: "年假",
|
||||
description: null,
|
||||
enabled: true,
|
||||
requiresAttachment: false,
|
||||
attachmentMimeTypes: [],
|
||||
sortOrder: 1,
|
||||
policy: {
|
||||
id: "leave-policy-annual",
|
||||
annualQuotaDays: 15,
|
||||
enabled: true,
|
||||
description: null,
|
||||
},
|
||||
createdAt: "2026-05-06T00:00:00.000Z",
|
||||
updatedAt: "2026-05-06T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function navigationItem(key: string, href: string, label: string, icon: string) {
|
||||
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
|
||||
}
|
||||
|
||||
function reimbursementItem(status = "draft") {
|
||||
return {
|
||||
id: "reimbursement-r101-draft",
|
||||
formNumber: "BX20260506001",
|
||||
status,
|
||||
reimbursementDate: "2026-05-06",
|
||||
totalAmount: "168.30",
|
||||
description: "客户拜访交通费",
|
||||
payment: {
|
||||
paymentVoucherNo: null,
|
||||
paymentDate: null,
|
||||
paymentChannel: null,
|
||||
paidAt: null,
|
||||
paidBy: null,
|
||||
},
|
||||
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||
submitter: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||
project: null,
|
||||
pendingWorkflow: null,
|
||||
createdAt: "2026-05-06T09:00:00.000Z",
|
||||
updatedAt: "2026-05-06T09:10:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
const leaveRequest = {
|
||||
id: "leave-r101-draft",
|
||||
ownerId: me.id,
|
||||
departmentId: me.departmentId,
|
||||
status: "draft",
|
||||
startDate: "2026-05-08",
|
||||
endDate: "2026-05-08",
|
||||
startSession: "am",
|
||||
endSession: "pm",
|
||||
requestedDays: 1,
|
||||
reason: "个人事务",
|
||||
attachment: null,
|
||||
leaveType: {
|
||||
id: leaveType.id,
|
||||
code: leaveType.code,
|
||||
name: leaveType.name,
|
||||
requiresAttachment: false,
|
||||
attachmentMimeTypes: [],
|
||||
},
|
||||
owner: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||
createdAt: "2026-05-06T09:00:00.000Z",
|
||||
updatedAt: "2026-05-06T09:10:00.000Z",
|
||||
};
|
||||
|
||||
async function mockMobileApis(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/sso/providers") {
|
||||
await fulfillJson(route, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/wecom/config") {
|
||||
await fulfillJson(route, {
|
||||
enabled: true,
|
||||
entryEnabled: true,
|
||||
oauthEnabled: true,
|
||||
corpId: "ww-r101",
|
||||
agentId: "1000002",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
|
||||
bindEnabled: true,
|
||||
defaultReturnTo: "/mobile",
|
||||
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-r101-token", expiresIn: 900, mustChangePassword: false })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/me") {
|
||||
await fulfillJson(route, me);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/system/navigation") {
|
||||
await fulfillJson(route, {
|
||||
workspace: {
|
||||
id: "workspace-r101",
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "wecom",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "mobile",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
},
|
||||
sections: {
|
||||
main: [
|
||||
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
|
||||
navigationItem("leave", "/leave-requests", "请假", "event_available"),
|
||||
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
|
||||
],
|
||||
admin: [],
|
||||
backup: [],
|
||||
},
|
||||
recommendedRolePresets: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/portal/home") {
|
||||
await fulfillJson(route, {
|
||||
workspace: { id: "workspace-r101", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: me.name },
|
||||
cards: [
|
||||
{ key: "pending_tasks", title: "待处理", value: 0, href: "/workflow/tasks", severity: "normal" },
|
||||
{ key: "my_applications", title: "我的申请", value: 2, href: "/reimbursements", severity: "normal" },
|
||||
],
|
||||
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
||||
myApplications: [
|
||||
{ domain: "reimbursement", count: 1, href: "/reimbursements" },
|
||||
{ domain: "leave", count: 1, href: "/leave-requests" },
|
||||
],
|
||||
riskSummary: null,
|
||||
announcements: [],
|
||||
quickLinks: [{ key: "capture", title: "上传发票", href: "/mobile/capture" }],
|
||||
todayPresence: { date: "2026-05-06", status: "office", label: "在岗", note: null, href: "/team" },
|
||||
teamPresence: null,
|
||||
weeklyReport: { weekStart: "2026-05-04", status: "draft", needsHelp: false, href: "/weekly-reports" },
|
||||
weeklySummary: null,
|
||||
todoSummary: { openCount: 0, highPriorityCount: 0, href: "/todos" },
|
||||
projectSummary: null,
|
||||
peopleLifecycle: null,
|
||||
adoptionSummary: null,
|
||||
recentActivities: [],
|
||||
teamStatusBoard: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/attendance/me/today") {
|
||||
await fulfillJson(route, {
|
||||
workDate: "2026-05-06",
|
||||
needClockIn: true,
|
||||
exemptionReason: null,
|
||||
item: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.startsWith("/api/employees/") && path.endsWith("/profile")) {
|
||||
await fulfillJson(route, {
|
||||
employee: {
|
||||
...me,
|
||||
avatarUrl: null,
|
||||
department: { id: "dept-r101", name: "研发部" },
|
||||
manager: null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/reimbursements" && request.method() === "GET") {
|
||||
const status = url.searchParams.get("status") ?? "draft";
|
||||
const items = status === "draft" ? [reimbursementItem(status)] : [];
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: items.length,
|
||||
scope: "own",
|
||||
items,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/reimbursements" && request.method() === "POST") {
|
||||
await fulfillJson(route, {
|
||||
id: "reimbursement-r101-created",
|
||||
formNumber: null,
|
||||
submitterId: me.id,
|
||||
ownerId: me.id,
|
||||
reimbursementDate: "2026-05-06",
|
||||
status: "draft",
|
||||
description: null,
|
||||
projectId: null,
|
||||
project: null,
|
||||
totalAmount: "0",
|
||||
linkedTravelRequestId: null,
|
||||
linkedTravelRequest: null,
|
||||
items: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/leave-types") {
|
||||
await fulfillJson(route, { items: [leaveType] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/leave-balances") {
|
||||
await fulfillJson(route, {
|
||||
employeeId: me.id,
|
||||
items: [
|
||||
{
|
||||
leaveType,
|
||||
policy: leaveType.policy,
|
||||
granted: 15,
|
||||
adjusted: 0,
|
||||
reserved: 1,
|
||||
used: 2,
|
||||
available: 12,
|
||||
periodStart: "2026-01-01",
|
||||
periodEnd: "2026-12-31",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/leave-requests") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
scope: "own",
|
||||
items: [leaveRequest],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/notifications") {
|
||||
await fulfillJson(route, {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
unreadCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/workflow/tasks") {
|
||||
await fulfillJson(route, { items: [], total: 0, page: 1, pageSize: 20 });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, {});
|
||||
});
|
||||
}
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByTestId("login-identifier").fill("[email protected]");
|
||||
await page.getByTestId("login-password").fill("EmployeePassword1234");
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
}
|
||||
|
||||
async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) {
|
||||
await testInfo.attach(name, {
|
||||
body: await page.screenshot({ fullPage: true }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
|
||||
async function assertNoMobileDesignRegression(page: Page) {
|
||||
await expect(page.getByText("先处理这几件事")).toHaveCount(0);
|
||||
await expect(page.getByText("清爽", { exact: true })).toHaveCount(0);
|
||||
await expect(page.locator("table:visible")).toHaveCount(0);
|
||||
|
||||
const darkControls = await page.evaluate(() => {
|
||||
function isVisible(element: Element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
return Array.from(document.querySelectorAll("a, button, [role='button'], [role='tab']"))
|
||||
.filter(isVisible)
|
||||
.map((element) => {
|
||||
const styles = window.getComputedStyle(element);
|
||||
return {
|
||||
text: (element.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 40),
|
||||
backgroundColor: styles.backgroundColor,
|
||||
color: styles.color,
|
||||
};
|
||||
})
|
||||
.filter((item) => {
|
||||
const darkBackgrounds = new Set(["rgb(0, 0, 0)", "rgb(24, 24, 27)", "rgb(15, 23, 42)"]);
|
||||
return darkBackgrounds.has(item.backgroundColor) && item.color === "rgb(255, 255, 255)";
|
||||
});
|
||||
});
|
||||
|
||||
expect(darkControls).toEqual([]);
|
||||
}
|
||||
|
||||
test.describe("R101 mobile WIP closeout", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await mockMobileApis(page);
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test("移动首页和我的页收口", async ({ page }, testInfo) => {
|
||||
await page.goto("/mobile");
|
||||
await expect(page.getByRole("heading", { name: "今天" })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
|
||||
await expect(page.locator("aside")).toHaveCount(0);
|
||||
await assertNoMobileDesignRegression(page);
|
||||
await attachScreenshot(page, testInfo, "r101-mobile-home.png");
|
||||
|
||||
await page.goto("/mobile/me");
|
||||
await expect(page.getByRole("heading", { name: "我的" })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-more-links")).toBeVisible();
|
||||
await expect(page.getByText("低频模块放在这里")).toHaveCount(0);
|
||||
await assertNoMobileDesignRegression(page);
|
||||
await attachScreenshot(page, testInfo, "r101-mobile-me.png");
|
||||
});
|
||||
|
||||
test("移动报销页使用卡片入口", async ({ page }, testInfo) => {
|
||||
await page.goto("/mobile/reimbursements");
|
||||
await expect(page.getByRole("heading", { name: "报销" })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-reimbursements-create")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-reimbursement-card")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "继续编辑" })).toBeVisible();
|
||||
await assertNoMobileDesignRegression(page);
|
||||
await attachScreenshot(page, testInfo, "r101-mobile-reimbursements.png");
|
||||
});
|
||||
|
||||
test("移动请假页使用表单和卡片记录", async ({ page }, testInfo) => {
|
||||
await page.goto("/mobile/leave");
|
||||
await expect(page.getByRole("heading", { name: "请假", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-leave-summary")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-leave-form")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "选择附件" })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-leave-record-card")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "编辑" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "提交" })).toBeVisible();
|
||||
await assertNoMobileDesignRegression(page);
|
||||
await attachScreenshot(page, testInfo, "r101-mobile-leave.png");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
A1,R101 移动端收口合同,"以 R100 设计规则为事实源,取代未提交的 R99 移动端 WIP 合同,收口企业微信/手机端首页、我的、报销、请假入口。","本 CSV 校验通过;R99 未提交合同不再作为本轮执行源;本轮不修改后端状态机和报销数据模型。","python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-06_22-22-09-r101-mobile-wip-closeout.csv;业务流程影响:仅固定移动端收口执行范围。","python|manual",DONE,DONE,DONE,"issues/2026-05-06_22-22-09-r101-mobile-wip-closeout.csv","[]","R99 WIP 被 R101 吸收或清理;不提交密钥;回滚路径为 revert 本批提交。"
|
||||
A2,移动首页与我的页收口,"移除企业微信手机端口语化文案、黑色主卡、黑色底部选中态,降低我的页头像和说明文案视觉权重,并隐藏移动路由的桌面顶栏/侧栏。","手机视口 /mobile 不出现 先处理这几件事 或 清爽;底部当前项不使用黑色块;/mobile/me 头像不过大且无长段解释;移动路由不出现桌面菜单抽屉。","cd client && npx playwright test tests/e2e/r101-mobile-wip-closeout.spec.ts --reporter=list;业务流程影响:登录后进入移动首页和我的页,检查常用入口与个人信息可用。","playwright|typescript",DONE,DONE,DONE,"client/src/pages/MobilePortalPage.tsx|client/src/pages/MobileMePage.tsx|client/src/components/mobile/MobileBottomNav.tsx|client/src/layouts/AppShellLayout.tsx","[""A1""]","遵循 docs/product-design-rules.md;不改变权限模型。"
|
||||
A3,移动报销入口收口,"新增或吸收移动报销页,移动首页和我的页报销入口导向移动卡片视图,避免手机端直接进入桌面表格页。","/mobile/reimbursements 可访问;首屏有新建报销、状态分段和卡片/空状态;草稿卡显示继续编辑;手机视口不显示桌面表格。","cd client && npx playwright test tests/e2e/r101-mobile-wip-closeout.spec.ts --reporter=list;业务流程影响:员工进入报销移动页、创建草稿入口可见、列表可按状态切换。","playwright|typescript",DONE,DONE,DONE,"client/src/pages/MobileReimbursementsPage.tsx|client/src/router/index.tsx|client/src/router/lazy/index.ts|client/src/router/lazy/MobileReimbursementsPage.ts","[""A2""]","暂不解决业务编号生成,UUID 回退留给 R102 报销主流程重构。"
|
||||
A4,移动请假入口收口,"新增或吸收移动请假页,移动首页和我的页请假入口导向移动表单和卡片记录,避免手机端直接进入桌面表格页,并将附件上传改为中文自定义控件。","/mobile/leave 可访问;首屏有新建请假、额度摘要和请假记录;草稿/驳回记录可编辑;手机视口不显示桌面表格;附件区域显示 选择附件 和文件名回显。","cd client && npx playwright test tests/e2e/r101-mobile-wip-closeout.spec.ts --reporter=list;业务流程影响:员工进入请假移动页、填写草稿入口可见、记录可按状态切换。","playwright|typescript",DONE,DONE,DONE,"client/src/pages/MobileLeavePage.tsx|client/src/router/index.tsx|client/src/router/lazy/index.ts|client/src/router/lazy/MobileLeavePage.ts|scripts/qa/check-ui-design-rules.mjs","[""A2""]","本批仅处理移动请假附件控件;其他页面上传控件留给 R103。"
|
||||
A5,移动端视觉自动化回归,"新增 R101 Playwright 用例,使用 mock API 覆盖移动首页、我的、移动报销、移动请假,记录截图并断言关键设计规则。","R101 Playwright 用例通过;断言无口语化文案、无可见桌面表格、无黑色移动主导航/主卡;生成测试截图附件。","cd client && npx playwright test tests/e2e/r101-mobile-wip-closeout.spec.ts --reporter=list;业务流程影响:模拟员工登录后走移动核心入口。","playwright",DONE,DONE,DONE,"client/tests/e2e/r101-mobile-wip-closeout.spec.ts","[""A2"",""A3"",""A4""]","使用 mock API,不依赖真实企业微信或生产账号。"
|
||||
A6,审计产物归档与工作区清理,"检查 r99/r100 审计产物是否包含密钥或密码;将必要 JSON/截图作为证据归档,或清理不需要纳入 git 的旧 WIP 合同。","git status 不再出现未决的 R99/R100 审计产物和旧 R99 合同;若提交审计产物,确认不含 secret/password/token。","rg -n -i 'secret|password|token|authorization|cookie' artifacts/r99-mobile-feedback-audit artifacts/r100-pc-reimbursement-audit;业务流程影响:不改变运行时,仅保留可审计证据。","rg|git|manual",DONE,DONE,DONE,"artifacts/r99-mobile-feedback-audit|artifacts/r100-pc-reimbursement-audit|issues/2026-05-06_21-16-33-r99-wecom-mobile-ui-remediation.csv","[""A1""]","旧 R99 合同由 R101 取代;未提交过时草稿已清理;R100 审计 JSON 已脱敏。"
|
||||
A7,验证回填与中文提交,"执行 CSV 校验、UI 诊断、前端类型检查、构建和 R101 Playwright 回归,并中文提交本批。","A1-A6 状态按实测回填;git diff --check 通过;提交信息中文且引用 R101/A1-A7;提交范围不包含无关文件。","python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-06_22-22-09-r101-mobile-wip-closeout.csv;node scripts/qa/check-ui-design-rules.mjs;cd client && npm run typecheck;cd client && npm run build;cd client && npx playwright test tests/e2e/r101-mobile-wip-closeout.spec.ts --reporter=list;业务流程影响:确认移动端收口不破坏前端构建。","python|node|npm|playwright|git",DONE,DONE,DONE,"issues/2026-05-06_22-22-09-r101-mobile-wip-closeout.csv|client","[""A1"",""A2"",""A3"",""A4"",""A5"",""A6""]","提交后继续评估是否进入 R102 报销主流程。"
|
||||
|
@@ -104,6 +104,11 @@ function scanNativeFileInputs(file, source) {
|
||||
const fileInputPattern = /<input\b[^>]*\btype=["']file["'][^>]*>/g;
|
||||
let match = fileInputPattern.exec(source);
|
||||
while (match) {
|
||||
if (/\bhidden\b/.test(match[0])) {
|
||||
match = fileInputPattern.exec(source);
|
||||
continue;
|
||||
}
|
||||
|
||||
addFinding({
|
||||
rule: "form.no_native_file_input_visible",
|
||||
severity: "warning",
|
||||
|
||||