Initial role user app
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
ACCESS_MANAGE_API_BASE_URL=http://localhost:3500/api
|
||||||
|
ROLE_USER_SESSION_COOKIE=role_user_session
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
*.log
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
.playwright-mcp
|
||||||
|
role-user-*.png
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# role-user
|
||||||
|
|
||||||
|
员工端 C 端工作台,基于 `access-manage` 后端和 `role-admin` 管理后台建设。
|
||||||
|
|
||||||
|
当前已搭建 React + Next.js App Router + TypeScript 前端骨架,采用移动优先、PWA 友好的工作台形态。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
cp .env.example .env.local
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问:
|
||||||
|
|
||||||
|
- 登录页:http://localhost:3210/login
|
||||||
|
- 工作台:http://localhost:3210/dashboard
|
||||||
|
- 任务:http://localhost:3210/tasks
|
||||||
|
- 排班:http://localhost:3210/schedule
|
||||||
|
- 公告:http://localhost:3210/announcements
|
||||||
|
- 门店:http://localhost:3210/store
|
||||||
|
- 我的:http://localhost:3210/me
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ACCESS_MANAGE_API_BASE_URL=http://localhost:3500/api
|
||||||
|
ROLE_USER_SESSION_COOKIE=role_user_session
|
||||||
|
```
|
||||||
|
|
||||||
|
`ACCESS_MANAGE_API_BASE_URL` 指向 `access-manage` 服务端 API 根路径。员工登录会调用后端 `POST /api/auth/employee/login`,登录成功后只把 JWT 写入 Next.js 服务端 HttpOnly Cookie,不写入 `localStorage`。
|
||||||
|
|
||||||
|
## 可用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前实现范围
|
||||||
|
|
||||||
|
- Next.js App Router 项目结构、TypeScript、ESLint、`@/*` 路径别名和基础样式。
|
||||||
|
- BFF Route Handlers:
|
||||||
|
- `POST /api/auth/login`
|
||||||
|
- `POST|GET /api/auth/logout`
|
||||||
|
- `GET /api/auth/me`
|
||||||
|
- `PATCH /api/auth/me/password`
|
||||||
|
- `GET /api/permissions/me`
|
||||||
|
- `GET /api/mobile/tasks`
|
||||||
|
- `GET /api/mobile/tasks/:id`
|
||||||
|
- `POST /api/mobile/tasks/:id/start`
|
||||||
|
- `POST /api/mobile/tasks/:id/complete`
|
||||||
|
- `POST /api/mobile/tasks/:id/comment`
|
||||||
|
- `GET /api/mobile/announcements`
|
||||||
|
- `GET /api/mobile/announcements/:id`
|
||||||
|
- `POST /api/mobile/announcements/:id/read`
|
||||||
|
- `GET /api/mobile/shifts`
|
||||||
|
- `GET /api/mobile/shifts/today`
|
||||||
|
- `GET /api/mobile/store`
|
||||||
|
- `GET /api/mobile/store/employees`
|
||||||
|
- 页面:
|
||||||
|
- `/login`
|
||||||
|
- `/dashboard`
|
||||||
|
- `/tasks`
|
||||||
|
- `/tasks/:id`
|
||||||
|
- `/schedule`
|
||||||
|
- `/announcements`
|
||||||
|
- `/announcements/:id`
|
||||||
|
- `/store`
|
||||||
|
- `/me`
|
||||||
|
- 首页已消费正式 `mobile/bootstrap` 的 `latestAnnouncements`、`tasks`、`overdueTaskCount`、`todayShift/todayShifts`。
|
||||||
|
- `/announcements` 支持全部、未读、已读筛选;公告详情进入后通过 BFF 标记已读。
|
||||||
|
- `/tasks` 支持全部、待处理、处理中、已完成筛选;任务详情支持开始、完成和追加备注。
|
||||||
|
- `/schedule` 支持今日、未来、全部排班筛选。
|
||||||
|
- `/me` 已接入本人修改密码表单,前端不持久化密码或 token。
|
||||||
|
- `/dashboard`、`/tasks`、`/announcements`、`/schedule` 会在后端临时不可用或 5xx 时保持基础资料和业务空态。
|
||||||
|
- `/store` 会尝试读取当前门店详情和本店员工列表;后端权限或接口不可用时保持门店基础信息和空态。
|
||||||
|
- 移动端底部导航、加载态、空态、会话缺失跳登录、401 清理 Cookie 的服务端路径。
|
||||||
|
- PWA manifest、应用图标、基础 service worker、安全响应头和参照 SeaCloud 的移动端工作台视觉系统。
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- `docs/C_EMPLOYEE_APP_REQUIREMENTS.md`: C 端员工工作台需求文档。
|
||||||
|
- `docs/FULLSTACK_BACKEND_GAP_ANALYSIS.md`: C 端员工工作台三端缺口与改动范围分析。
|
||||||
|
- `RTK.md`: 本项目 Codex/Agent 协作规则。
|
||||||
|
|
||||||
|
## 关联项目
|
||||||
|
|
||||||
|
- `/Users/mac033/Desktop/my-project/access-manage`: 服务端 API。
|
||||||
|
- `/Users/mac033/Desktop/my-project/role-admin`: 管理后台。
|
||||||
|
|
||||||
|
## 安全边界
|
||||||
|
|
||||||
|
- 不展示任何明文密码。
|
||||||
|
- 不把密码、JWT 或权限 token 持久化到前端存储。
|
||||||
|
- 员工端修改密码已通过 BFF 接入后端 `PATCH /api/auth/me/password`。
|
||||||
|
- 公告、任务、排班已按正式后端接口接入列表、筛选、详情和员工端操作;后端临时不可用时页面保留业务空态。
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# role-user Project Notes
|
||||||
|
|
||||||
|
This project is the employee-facing C-side app for the `access-manage` backend and the `role-admin` management console.
|
||||||
|
|
||||||
|
When working in this repository:
|
||||||
|
|
||||||
|
- Prefer Chinese for project notes, PRDs, comments, and delivery summaries.
|
||||||
|
- Use React + Next.js App Router + TypeScript as the default frontend stack.
|
||||||
|
- Keep the app mobile-first and PWA-friendly; desktop should work, but the primary user is a store employee using a phone.
|
||||||
|
- Treat `access-manage/docs/ROLE_USER_BACKEND_REQUIREMENTS.md` as the backend contract source and `docs/C_EMPLOYEE_APP_REQUIREMENTS.md` as the frontend product source.
|
||||||
|
- Do not store or expose plaintext passwords in the frontend. Password operations must use reset/change flows, temporary one-time passwords, and audit logs.
|
||||||
|
- Use a Backend-for-Frontend layer in Next.js Route Handlers when it improves session safety. Prefer HttpOnly cookies over localStorage for tokens.
|
||||||
|
- Keep business API types and request helpers centralized under `src/lib/` once the app is scaffolded.
|
||||||
|
- When files or directories are added, removed, renamed, or reorganized, update `README.md` in the same change once a README exists.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# role-user C 端员工工作台需求文档
|
||||||
|
|
||||||
|
## 1. 项目定位
|
||||||
|
|
||||||
|
`role-user` 是门店员工使用的 C 端工作台,和现有两个项目形成三端分工:
|
||||||
|
|
||||||
|
| 项目 | 定位 | 主要用户 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `access-manage` | 服务端 API、鉴权、权限、门店数据和业务数据 | 所有前端 |
|
||||||
|
| `role-admin` | 管理后台,维护门店、员工、角色、权限和 C 端运营内容 | 超级管理员、管理员、店长 |
|
||||||
|
| `role-user` | 员工端 C 端应用,承载日常工作、通知、任务、排班和个人中心 | 店长、收银员、后厨、兼职 |
|
||||||
|
|
||||||
|
本项目不是另一个管理后台,也不是顾客下单端。第一版正式产品应聚焦“员工每天打开后能完成门店日常工作”。
|
||||||
|
|
||||||
|
## 2. 用户角色
|
||||||
|
|
||||||
|
| 角色 | 典型职责 | C 端能力 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 店长 `store_manager` | 管理本店员工、查看门店任务和公告 | 查看本店员工、处理门店任务、查看排班和公告 |
|
||||||
|
| 收银员 `cashier` | 收银、核对订单、基础会员操作 | 查看个人任务、公告、排班和门店信息 |
|
||||||
|
| 后厨 `kitchen` | 出品、备货、库存协作 | 查看后厨任务、公告、排班 |
|
||||||
|
| 兼职 `part_time` | 临时排班和基础任务 | 查看个人任务、排班、公告 |
|
||||||
|
| 管理员 `admin` | 管理多门店业务 | 默认走 `role-admin`,如进入 C 端则展示员工视角 |
|
||||||
|
|
||||||
|
## 3. 功能范围
|
||||||
|
|
||||||
|
### 3.1 登录与会话
|
||||||
|
|
||||||
|
- 员工使用手机号和密码登录。
|
||||||
|
- 登录接口走 `POST /api/auth/employee/login`。
|
||||||
|
- 登录成功后读取 `GET /api/auth/me` 和 `GET /api/permissions/me`。
|
||||||
|
- Next.js BFF 负责保存服务端会话,前端不直接把 JWT 放进 `localStorage`。
|
||||||
|
- 后端没有 refresh token 时,401 直接清理会话并跳回登录页。
|
||||||
|
|
||||||
|
### 3.2 工作台首页
|
||||||
|
|
||||||
|
首页用于高频扫描,不做营销页:
|
||||||
|
|
||||||
|
- 今日身份卡:员工姓名、所属门店、角色、账号状态。
|
||||||
|
- 今日排班:当前班次、上下班时间、岗位。
|
||||||
|
- 待办任务:未完成、即将超时、已逾期数量和列表入口。
|
||||||
|
- 最新公告:未读公告摘要。
|
||||||
|
- 快捷入口:任务、排班、门店、我的。
|
||||||
|
|
||||||
|
### 3.3 公告中心
|
||||||
|
|
||||||
|
- 展示管理员或店长发布给当前员工、角色或门店的公告。
|
||||||
|
- 支持未读、已读筛选。
|
||||||
|
- 进入公告详情后标记已读。
|
||||||
|
- 重要公告需要在首页突出显示。
|
||||||
|
|
||||||
|
### 3.4 任务中心
|
||||||
|
|
||||||
|
- 展示分配给当前员工或当前门店的任务。
|
||||||
|
- 状态:待处理、处理中、已完成、已取消。
|
||||||
|
- 支持按状态、截止时间筛选。
|
||||||
|
- 员工可以更新自己的任务状态并填写处理备注。
|
||||||
|
- 店长可以查看本店任务汇总;跨店管理仍放在后台。
|
||||||
|
|
||||||
|
### 3.5 排班
|
||||||
|
|
||||||
|
- 展示个人本周/本月排班。
|
||||||
|
- 支持今日班次和未来班次。
|
||||||
|
- 第一版只做查看,不做员工端自行换班。
|
||||||
|
|
||||||
|
### 3.6 门店信息
|
||||||
|
|
||||||
|
- 展示当前门店名称、地址、电话、状态。
|
||||||
|
- 店长或有 `employee:view:store` 权限的员工可查看本店员工列表。
|
||||||
|
- 不在 C 端提供门店新增、删除、角色分配等后台能力。
|
||||||
|
|
||||||
|
### 3.7 我的
|
||||||
|
|
||||||
|
- 展示个人资料、手机号、门店、角色、最近登录时间。
|
||||||
|
- 支持修改自己的登录密码:旧密码 + 新密码。
|
||||||
|
- 支持退出登录。
|
||||||
|
- 不支持查看任何人的明文密码。
|
||||||
|
|
||||||
|
## 4. 密码与凭据安全要求
|
||||||
|
|
||||||
|
用户提出“超级管理员与管理员需要支持查看自身和下级用户密码”。正式实现不做明文密码展示,原因:
|
||||||
|
|
||||||
|
- 当前后端只保存 `password_hash`,技术上无法反查原密码。
|
||||||
|
- 如果为了展示密码而保存明文或可逆密文,会扩大泄露面,不适合作为正式产品默认能力。
|
||||||
|
|
||||||
|
正式替代方案:
|
||||||
|
|
||||||
|
- 后台提供“重置下级用户密码”。
|
||||||
|
- 重置后生成一次性临时密码,只在本次响应和后台弹窗中显示一次。
|
||||||
|
- 员工下次登录后应被要求修改密码。
|
||||||
|
- 所有密码重置行为写入审计日志:操作者、目标用户、时间、IP、User-Agent、原因。
|
||||||
|
- 本人密码只支持“修改密码”,不支持“查看当前密码”。
|
||||||
|
|
||||||
|
## 5. 信息架构
|
||||||
|
|
||||||
|
底部导航:
|
||||||
|
|
||||||
|
| 路由 | 页面 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/dashboard` | 工作台 | 默认首页 |
|
||||||
|
| `/tasks` | 任务 | 个人和本店任务 |
|
||||||
|
| `/schedule` | 排班 | 个人排班 |
|
||||||
|
| `/announcements` | 公告 | 公告列表和详情 |
|
||||||
|
| `/me` | 我的 | 个人资料、改密、退出 |
|
||||||
|
|
||||||
|
辅助路由:
|
||||||
|
|
||||||
|
| 路由 | 页面 |
|
||||||
|
| --- | --- |
|
||||||
|
| `/login` | 登录 |
|
||||||
|
| `/store` | 当前门店 |
|
||||||
|
| `/tasks/:id` | 任务详情 |
|
||||||
|
| `/announcements/:id` | 公告详情 |
|
||||||
|
|
||||||
|
## 6. 技术要求
|
||||||
|
|
||||||
|
- 使用 Next.js App Router、React、TypeScript。
|
||||||
|
- 移动优先,支持 PWA 安装。
|
||||||
|
- 使用 Server Components 承载只读数据首屏,交互表单使用 Client Components。
|
||||||
|
- 使用 Route Handlers 做 BFF:`role-user` 调自己的 `/api/*`,BFF 再访问 `access-manage`。
|
||||||
|
- 会话 token 通过 HttpOnly Cookie 保存。
|
||||||
|
- 服务端请求用户态接口时禁用共享缓存。
|
||||||
|
- 首页独立数据并行请求,避免瀑布。
|
||||||
|
- API 类型集中维护,不在组件里散落接口路径。
|
||||||
|
|
||||||
|
## 7. 验收标准
|
||||||
|
|
||||||
|
- 员工可以使用手机号和密码登录。
|
||||||
|
- 登录后能看到自己的门店、角色、权限和基础信息。
|
||||||
|
- 底部导航在移动端可用,桌面端布局不破。
|
||||||
|
- 401 后清理会话并回到登录页。
|
||||||
|
- 密码不会出现在前端持久化存储中。
|
||||||
|
- 没有任何明文密码查询或展示接口。
|
||||||
|
- 管理后台重置出的临时密码仅显示一次,并有审计记录。
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# C 端员工工作台三端缺口分析
|
||||||
|
|
||||||
|
生成时间:2026-06-01
|
||||||
|
|
||||||
|
## 1. 结论
|
||||||
|
|
||||||
|
当前三端已经开始围绕 C 端员工工作台建设,但还没有完全闭环。
|
||||||
|
|
||||||
|
| 端 | 项目 | 当前状态 | 是否需要改 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 后端 | `access-manage` | 已新增公告、任务、排班、移动端、凭据审计模块,但首屏聚合、审计筛选、部分数据字段和业务校验还缺 | 是 |
|
||||||
|
| 后台 | `role-admin` | 已新增公告、任务、排班、凭据审计页面,但 API 类型仍按早期命名设计,和后端正式字段不一致 | 是 |
|
||||||
|
| 前台 | `role-user` | 已有移动端页面、BFF 和空态,但缺任务/公告详情、任务操作、改密表单、门店详情和首屏聚合字段消费完善 | 是 |
|
||||||
|
|
||||||
|
优先级建议:
|
||||||
|
|
||||||
|
1. 先补齐 `access-manage` 合同,保证 API 返回稳定。
|
||||||
|
2. 再修正 `role-admin` API 适配,保证运营内容能创建出来。
|
||||||
|
3. 最后完善 `role-user` 的员工端交互,消费这些正式接口。
|
||||||
|
|
||||||
|
## 2. 后端缺口
|
||||||
|
|
||||||
|
### 2.1 首屏聚合 `/api/mobile/bootstrap` 数据不足
|
||||||
|
|
||||||
|
现状:
|
||||||
|
|
||||||
|
- 已返回 `user`、`store`、`permissions`、`counters.unreadAnnouncementCount`、`counters.pendingTaskCount`、`todayShifts`。
|
||||||
|
- 没有返回首页需要直接展示的 `latestAnnouncements`、`tasks`。
|
||||||
|
- 没有返回 `overdueTaskCount`。
|
||||||
|
- `todayShifts` 是数组,员工端当前兼容数组,但接口语义应明确。
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 增加最近公告列表,最多 3 条。
|
||||||
|
- 增加待办任务列表,最多 5 条。
|
||||||
|
- 增加逾期任务数量。
|
||||||
|
- 明确返回 `todayShift` 或 `todayShifts`,前端统一消费。
|
||||||
|
|
||||||
|
### 2.2 凭据审计接口筛选和字段不完整
|
||||||
|
|
||||||
|
现状:
|
||||||
|
|
||||||
|
- `GET /api/admin/credential-audits` schema 只接收 `targetEmployeeId`、`page`、`pageSize`。
|
||||||
|
- `role-admin` 页面已经提交 `operatorId`、`storeId`、`startDate`、`endDate`,但后端没有完整接收。
|
||||||
|
- 返回值缺 `targetEmployeePhone`、`storeName`,后台页面正在展示这些字段。
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 后端 query schema 增加 `operatorId`、`storeId`、`startDate`、`endDate`。
|
||||||
|
- repository join `stores`,返回目标员工手机号和门店名。
|
||||||
|
- 明确操作者筛选对超级管理员和员工操作者的匹配规则。
|
||||||
|
|
||||||
|
### 2.3 后台运营字段合同与 `role-admin` 不一致
|
||||||
|
|
||||||
|
后端当前正式字段:
|
||||||
|
|
||||||
|
- 公告:`level`、`targetType`、`targets`。
|
||||||
|
- 任务:`dueAt`、`assignees`、`CANCELLED`。
|
||||||
|
- 排班:`roleName`、`CANCELLED`。
|
||||||
|
- 凭据审计:`actorName`、`createdAt`。
|
||||||
|
|
||||||
|
`role-admin` 当前字段:
|
||||||
|
|
||||||
|
- 公告:`importance`、`targetScope`、`targetStoreIds`、`targetRoleIds`、`targetEmployeeIds`。
|
||||||
|
- 任务:`deadlineAt`、`assigneeIds`、`assigneeNames`、`CANCELED`。
|
||||||
|
- 排班:`position`、`CANCELED`、`COMPLETED`。
|
||||||
|
- 凭据审计:`operatorName`、`operatedAt`、`targetEmployeePhone`、`storeName`。
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 优先在 `role-admin/src/api/access.ts` 做请求/响应适配,避免改动多个页面。
|
||||||
|
- 后端只补真正缺失的字段和筛选,不为了旧前端命名反向污染 API。
|
||||||
|
|
||||||
|
### 2.4 任务业务闭环还需加强
|
||||||
|
|
||||||
|
现状:
|
||||||
|
|
||||||
|
- 后台任务创建要求 `assigneeIds` 至少 1 个。
|
||||||
|
- C 端只能查到分配给自己的任务。
|
||||||
|
- 需求文档提到“个人和门店任务”,但后端还没有店铺级任务被所有本店员工可见的明确规则。
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 第一版若只做员工分配任务,应更新文档说明。
|
||||||
|
- 若要支持门店任务,需要允许 `assigneeIds` 为空,并把 `store_id` 命中的本店任务纳入 `/api/mobile/tasks`。
|
||||||
|
|
||||||
|
### 2.5 排班冲突校验缺失
|
||||||
|
|
||||||
|
现状:
|
||||||
|
|
||||||
|
- 已校验员工属于门店、结束时间晚于开始时间。
|
||||||
|
- 需求文档要求“同一员工同一时间段不能重复排班”,后端还没有冲突检测。
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- `create` 和 `update` 时检测同一员工未取消班次时间段重叠。
|
||||||
|
- 更新时排除当前排班 ID。
|
||||||
|
|
||||||
|
### 2.6 文档与实现需要同步
|
||||||
|
|
||||||
|
现状:
|
||||||
|
|
||||||
|
- `access-manage/docs/API.md` 已写入新增接口,但部分字段仍偏概要。
|
||||||
|
- `docs/ROLE_USER_BACKEND_REQUIREMENTS.md` 是目标合同,未标注当前缺口状态。
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 后端实现完成后补充 `bootstrap` 响应示例。
|
||||||
|
- 补充凭据审计筛选参数和返回字段。
|
||||||
|
|
||||||
|
## 3. 后台改动范围
|
||||||
|
|
||||||
|
项目:`/Users/mac033/Desktop/my-project/role-admin`
|
||||||
|
|
||||||
|
需要改:
|
||||||
|
|
||||||
|
- `src/api/access.ts`
|
||||||
|
- 把公告表单字段转换为后端 `level`、`targetType`、`targets`。
|
||||||
|
- 把后端公告返回转换为页面使用的 `importance`、`targetScope`、目标 ID 数组。
|
||||||
|
- 把任务 `deadlineAt` 转为 `dueAt`,`assignees` 转为 `assigneeIds` 和 `assigneeNames`。
|
||||||
|
- 统一 `CANCELLED` 与页面当前 `CANCELED` 的枚举。
|
||||||
|
- 把排班 `position` 转为 `roleName`。
|
||||||
|
- 把凭据审计 `actorName/createdAt` 转为 `operatorName/operatedAt`,并接收新增字段。
|
||||||
|
- 视图页面只在必要时微调,优先不大面积重写。
|
||||||
|
- `README.md` 同步新增模块和接口适配说明。
|
||||||
|
|
||||||
|
## 4. 前台改动范围
|
||||||
|
|
||||||
|
项目:`/Users/mac033/Desktop/my-project/role-user`
|
||||||
|
|
||||||
|
需要改:
|
||||||
|
|
||||||
|
- `src/lib/mobile-data.ts`
|
||||||
|
- 适配后端正式 `bootstrap` 的最新公告、任务、逾期数。
|
||||||
|
- 明确 `todayShift/todayShifts` 消费策略。
|
||||||
|
- BFF Route Handlers
|
||||||
|
- 增加公告详情、标记已读。
|
||||||
|
- 增加任务详情、开始、完成、备注。
|
||||||
|
- 增加本人修改密码。
|
||||||
|
- 可选:当前门店详情和本店员工列表代理。
|
||||||
|
- 页面
|
||||||
|
- `/announcements/:id` 公告详情并标记已读。
|
||||||
|
- `/tasks/:id` 任务详情、开始、完成、备注。
|
||||||
|
- `/me` 改密表单。
|
||||||
|
- `/store` 接入门店地址、电话和本店员工列表。
|
||||||
|
|
||||||
|
## 5. 拆分给子进程的任务
|
||||||
|
|
||||||
|
### 子进程 A:后端 `access-manage`
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 补齐 `mobile/bootstrap` 的首页数据。
|
||||||
|
- 补齐凭据审计筛选和返回字段。
|
||||||
|
- 增加排班冲突校验。
|
||||||
|
- 根据选择决定是否支持门店级任务。
|
||||||
|
- 更新 `docs/API.md`。
|
||||||
|
|
||||||
|
写入范围:
|
||||||
|
|
||||||
|
- `src/modules/mobile/*`
|
||||||
|
- `src/modules/tasks/*`
|
||||||
|
- `src/modules/shifts/*`
|
||||||
|
- `src/modules/credentials/*`
|
||||||
|
- `docs/API.md`
|
||||||
|
- 必要时新增迁移,但优先复用现有表结构。
|
||||||
|
|
||||||
|
### 子进程 B:后台 `role-admin`
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 修正 `src/api/access.ts` 与后端正式合同的字段适配。
|
||||||
|
- 保持页面现有交互不大改。
|
||||||
|
- 修正枚举值和凭据审计展示。
|
||||||
|
- 更新 `README.md`。
|
||||||
|
|
||||||
|
写入范围:
|
||||||
|
|
||||||
|
- `src/api/access.ts`
|
||||||
|
- 必要时 `src/views/announcements/index.vue`
|
||||||
|
- 必要时 `src/views/tasks/index.vue`
|
||||||
|
- 必要时 `src/views/shifts/index.vue`
|
||||||
|
- 必要时 `src/views/credential-audits/index.vue`
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
### 子进程 C:前台 `role-user`
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 完成员工端详情和操作闭环。
|
||||||
|
- 接入本人修改密码。
|
||||||
|
- 接入门店详情和本店员工列表。
|
||||||
|
- 更新 `README.md`。
|
||||||
|
|
||||||
|
写入范围:
|
||||||
|
|
||||||
|
- `src/lib/*`
|
||||||
|
- `src/app/api/*`
|
||||||
|
- `src/app/(app)/*`
|
||||||
|
- `src/components/*`
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
## 6. 验收顺序
|
||||||
|
|
||||||
|
1. 后端 `pnpm typecheck` 或现有检查命令通过。
|
||||||
|
2. 后台能用超级管理员创建公告、任务、排班,重置密码并看到审计。
|
||||||
|
3. 前台员工登录后能看到首页列表数据,进入详情并完成任务/公告操作。
|
||||||
|
4. 401 会清理员工端会话,不在浏览器存储 JWT 或密码。
|
||||||
|
5. 不存在明文密码查看接口。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
globalIgnores([".next/**", "out/**", "build/**", "node_modules/**", "next-env.d.ts"])
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
poweredByHeader: false,
|
||||||
|
typedRoutes: true,
|
||||||
|
turbopack: {
|
||||||
|
root: path.resolve(__dirname)
|
||||||
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: [
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/sw.js",
|
||||||
|
headers: [
|
||||||
|
{ key: "Content-Type", value: "application/javascript; charset=utf-8" },
|
||||||
|
{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
|
||||||
|
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self'" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "role-user",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3210",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"typecheck": "tsc --noEmit --incremental false"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.511.0",
|
||||||
|
"next": "latest",
|
||||||
|
"react": "latest",
|
||||||
|
"react-dom": "latest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
"@types/node": "^22.15.24",
|
||||||
|
"@types/react": "^19.1.6",
|
||||||
|
"@types/react-dom": "^19.1.5",
|
||||||
|
"eslint": "^9.28.0",
|
||||||
|
"eslint-config-next": "latest",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3746
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="96" fill="#ffffff"/>
|
||||||
|
<rect x="32" y="32" width="448" height="448" rx="72" fill="#f4f4f6"/>
|
||||||
|
<path d="M346 90 238 238l5 9 44 58c26-22 18-56 34-58 16-1 56 2 79-49 23-51-30-94-54-108Z" fill="#ffffff"/>
|
||||||
|
<path d="M164 86c31-2 59 12 75 36l70 86c17 22 44 35 74 33 29-2 54-18 68-42l-90 163c-1 3-3 5-5 8l-1 1c-14 22-38 38-66 40-28 2-54-10-71-30l-73-89c-17-24-45-39-76-37-29 2-54 18-68 42l92-166c13-25 39-43 70-45Z" fill="#131313"/>
|
||||||
|
<path d="M346 90 238 238l5 9 44 58c26-22 18-56 34-58 16-1 56 2 79-49 23-51-30-94-54-108Z" fill="url(#shine)"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="shine" x1="441" x2="201" y1="58" y2="236" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#ffffff"/>
|
||||||
|
<stop offset="1" stop-color="#000000"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 868 B |
@@ -0,0 +1,7 @@
|
|||||||
|
self.addEventListener("install", function () {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", function (event) {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ArrowLeft, Bell } from "lucide-react";
|
||||||
|
|
||||||
|
import { AnnouncementReadMarker } from "@/components/announcement-read-marker";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { StatusPill } from "@/components/status-pill";
|
||||||
|
import { BackendError } from "@/lib/backend";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
import { getAnnouncementDetail } from "@/lib/mobile-data";
|
||||||
|
import type { AnnouncementSummary } from "@/lib/types";
|
||||||
|
|
||||||
|
type AnnouncementDetailPageProps = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const levelText: Record<AnnouncementSummary["level"], string> = {
|
||||||
|
normal: "普通",
|
||||||
|
important: "重要",
|
||||||
|
urgent: "紧急"
|
||||||
|
};
|
||||||
|
|
||||||
|
const levelTone: Record<AnnouncementSummary["level"], "default" | "warning" | "danger"> = {
|
||||||
|
normal: "default",
|
||||||
|
important: "warning",
|
||||||
|
urgent: "danger"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AnnouncementDetailPage({ params }: AnnouncementDetailPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
let announcement: Awaited<ReturnType<typeof getAnnouncementDetail>>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
announcement = await getAnnouncementDetail(id);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BackendError && error.status === 404) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Link className="inline-link" href="/announcements">
|
||||||
|
<ArrowLeft aria-hidden size={16} />
|
||||||
|
返回公告
|
||||||
|
</Link>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow={formatDateTime(announcement.publishedAt)}
|
||||||
|
title={announcement.title}
|
||||||
|
description={announcement.summary}
|
||||||
|
/>
|
||||||
|
<AnnouncementReadMarker id={announcement.id} read={announcement.read} />
|
||||||
|
<section className="detail-card">
|
||||||
|
<div className="detail-meta">
|
||||||
|
<span>
|
||||||
|
<Bell aria-hidden size={16} />
|
||||||
|
公告级别
|
||||||
|
</span>
|
||||||
|
<StatusPill tone={levelTone[announcement.level]}>{levelText[announcement.level]}</StatusPill>
|
||||||
|
<StatusPill tone={announcement.read ? "default" : "warning"}>{announcement.read ? "已读" : "未读"}</StatusPill>
|
||||||
|
</div>
|
||||||
|
<article className="long-copy">{announcement.content}</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { Route } from "next";
|
||||||
|
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { FilterNav } from "@/components/filter-nav";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { StatusPill } from "@/components/status-pill";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
import { getAnnouncements } from "@/lib/mobile-data";
|
||||||
|
|
||||||
|
type AnnouncementFilter = "all" | "unread" | "read";
|
||||||
|
|
||||||
|
type AnnouncementsPageProps = {
|
||||||
|
searchParams: Promise<{ read?: string | string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function firstSearchValue(value: string | string[] | undefined) {
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilter(value: string | string[] | undefined): AnnouncementFilter {
|
||||||
|
const read = firstSearchValue(value);
|
||||||
|
if (read === "unread" || read === "read") return read;
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyCopy(filter: AnnouncementFilter) {
|
||||||
|
if (filter === "unread") {
|
||||||
|
return {
|
||||||
|
title: "暂无未读公告",
|
||||||
|
description: "当前没有需要你阅读的新公告。"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter === "read") {
|
||||||
|
return {
|
||||||
|
title: "暂无已读公告",
|
||||||
|
description: "你阅读过的公告会保留在这里,方便回看。"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: "暂无公告",
|
||||||
|
description: "当前没有面向你的门店公告。"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AnnouncementsPage({ searchParams }: AnnouncementsPageProps) {
|
||||||
|
const filter = normalizeFilter((await searchParams).read);
|
||||||
|
const allAnnouncements = await getAnnouncements();
|
||||||
|
const announcements = allAnnouncements.filter((item) => {
|
||||||
|
if (filter === "unread") return !item.read;
|
||||||
|
if (filter === "read") return item.read;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const empty = emptyCopy(filter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<PageHeader title="公告" description="接收管理员或店长发布给你的门店通知。" />
|
||||||
|
<FilterNav
|
||||||
|
label="公告筛选"
|
||||||
|
options={[
|
||||||
|
{ label: "全部", href: "/announcements" as Route, active: filter === "all" },
|
||||||
|
{ label: "未读", href: "/announcements?read=unread" as Route, active: filter === "unread" },
|
||||||
|
{ label: "已读", href: "/announcements?read=read" as Route, active: filter === "read" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{announcements.length > 0 ? (
|
||||||
|
<section className="list-stack">
|
||||||
|
{announcements.map((item) => {
|
||||||
|
const href = `/announcements/${item.id}` as Route;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link className="list-card" href={href} key={item.id}>
|
||||||
|
<div>
|
||||||
|
<h2>{item.title}</h2>
|
||||||
|
<p>{item.summary || formatDateTime(item.publishedAt)}</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill tone={item.level === "normal" ? "default" : "warning"}>
|
||||||
|
{item.read ? "已读" : "未读"}
|
||||||
|
</StatusPill>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<EmptyState title={empty.title} description={empty.description} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { Route } from "next";
|
||||||
|
import { Bell, CalendarDays, ChevronRight, ClipboardList } from "lucide-react";
|
||||||
|
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { StatusPill } from "@/components/status-pill";
|
||||||
|
import { formatDateTime, roleNames } from "@/lib/format";
|
||||||
|
import { getBootstrapData } from "@/lib/mobile-data";
|
||||||
|
import type { TaskStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
const taskStatusText: Record<TaskStatus, string> = {
|
||||||
|
pending: "待处理",
|
||||||
|
in_progress: "处理中",
|
||||||
|
completed: "已完成",
|
||||||
|
cancelled: "已取消"
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskStatusTone: Record<TaskStatus, "default" | "success" | "warning" | "danger"> = {
|
||||||
|
pending: "default",
|
||||||
|
in_progress: "warning",
|
||||||
|
completed: "success",
|
||||||
|
cancelled: "danger"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const data = await getBootstrapData();
|
||||||
|
const { user } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<section className="dashboard-hero">
|
||||||
|
<div className="dashboard-hero__content">
|
||||||
|
<p className="hero-kicker">{user.storeName || "员工端"}</p>
|
||||||
|
<h1>你好,{user.displayName}</h1>
|
||||||
|
<p>今日工作状态已同步,先处理最重要的事项。</p>
|
||||||
|
</div>
|
||||||
|
<div className="hero-stat-strip" aria-label="今日概览">
|
||||||
|
<span>
|
||||||
|
<strong>{data.pendingTaskCount}</strong>
|
||||||
|
待办
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{data.todayShifts.length}</strong>
|
||||||
|
班次
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{data.unreadAnnouncementCount}</strong>
|
||||||
|
未读
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="identity-card">
|
||||||
|
<div className="identity-card__media">
|
||||||
|
<span className="avatar">{user.displayName.slice(0, 1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="identity-card__body">
|
||||||
|
<h2>{user.displayName}</h2>
|
||||||
|
<p>{user.storeName || "未绑定门店"}</p>
|
||||||
|
<div className="pill-row">
|
||||||
|
<StatusPill tone="success">账号正常</StatusPill>
|
||||||
|
<StatusPill>{roleNames(user.roles)}</StatusPill>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="metric-grid">
|
||||||
|
<Link className="metric-tile" href="/tasks">
|
||||||
|
<ClipboardList aria-hidden size={20} />
|
||||||
|
<span>{data.pendingTaskCount}</span>
|
||||||
|
<p>待办任务</p>
|
||||||
|
</Link>
|
||||||
|
<Link className="metric-tile" href="/tasks">
|
||||||
|
<Bell aria-hidden size={20} />
|
||||||
|
<span>{data.overdueTaskCount}</span>
|
||||||
|
<p>已逾期</p>
|
||||||
|
</Link>
|
||||||
|
<Link className="metric-tile" href="/announcements">
|
||||||
|
<Bell aria-hidden size={20} />
|
||||||
|
<span>{data.unreadAnnouncementCount}</span>
|
||||||
|
<p>未读公告</p>
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<div className="section-title">
|
||||||
|
<h2>今日排班</h2>
|
||||||
|
<CalendarDays aria-hidden size={20} />
|
||||||
|
</div>
|
||||||
|
{data.todayShifts.length > 0 ? (
|
||||||
|
<div className="list-stack">
|
||||||
|
{data.todayShifts.map((shift) => (
|
||||||
|
<article className="list-card" key={shift.id}>
|
||||||
|
<div>
|
||||||
|
<h3>{shift.position}</h3>
|
||||||
|
<p>
|
||||||
|
{formatDateTime(shift.startAt)} - {formatDateTime(shift.endAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill tone="success">已排班</StatusPill>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="今日暂无排班" description="今天没有安排班次,可在排班页查看未来排班。" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<div className="section-title">
|
||||||
|
<h2>待办任务</h2>
|
||||||
|
<Link href="/tasks">
|
||||||
|
查看全部
|
||||||
|
<ChevronRight aria-hidden size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{data.tasks.length > 0 ? (
|
||||||
|
<div className="list-stack">
|
||||||
|
{data.tasks.map((task) => {
|
||||||
|
const href = `/tasks/${task.id}` as Route;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link className="list-card" href={href} key={task.id}>
|
||||||
|
<div>
|
||||||
|
<h3>{task.title}</h3>
|
||||||
|
<p>{task.description || `截止:${formatDateTime(task.dueAt)}`}</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill tone={taskStatusTone[task.status]}>{taskStatusText[task.status]}</StatusPill>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="暂无待办任务" description="待处理和处理中任务会显示在这里。" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-block">
|
||||||
|
<div className="section-title">
|
||||||
|
<h2>最新公告</h2>
|
||||||
|
<Link href="/announcements">
|
||||||
|
查看全部
|
||||||
|
<ChevronRight aria-hidden size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{data.latestAnnouncements.length > 0 ? (
|
||||||
|
<div className="list-stack">
|
||||||
|
{data.latestAnnouncements.map((item) => {
|
||||||
|
const href = `/announcements/${item.id}` as Route;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link className="list-card" href={href} key={item.id}>
|
||||||
|
<div>
|
||||||
|
<h3>{item.title}</h3>
|
||||||
|
<p>{item.summary || formatDateTime(item.publishedAt)}</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill tone={item.read ? "default" : "warning"}>{item.read ? "已读" : "未读"}</StatusPill>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="暂无公告" description="当前没有面向你的门店公告。" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { BottomNav } from "@/components/bottom-nav";
|
||||||
|
|
||||||
|
export default function AppLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<main className="app-main">{children}</main>
|
||||||
|
<BottomNav />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<div className="skeleton skeleton-title" />
|
||||||
|
<div className="skeleton skeleton-card" />
|
||||||
|
<div className="skeleton skeleton-card" />
|
||||||
|
<div className="skeleton skeleton-card" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { KeyRound, ShieldCheck, UserRound } from "lucide-react";
|
||||||
|
|
||||||
|
import { LogoutButton } from "@/components/logout-button";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { PasswordChangeForm } from "@/components/password-change-form";
|
||||||
|
import { StatusPill } from "@/components/status-pill";
|
||||||
|
import { roleNames } from "@/lib/format";
|
||||||
|
import { getCurrentUser } from "@/lib/mobile-data";
|
||||||
|
|
||||||
|
export default async function MePage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<PageHeader title="我的" description="个人资料、账号安全和退出登录。" />
|
||||||
|
<section className="profile-card">
|
||||||
|
<span className="avatar large">{user.displayName.slice(0, 1)}</span>
|
||||||
|
<h2>{user.displayName}</h2>
|
||||||
|
<p>{user.username}</p>
|
||||||
|
<div className="pill-row">
|
||||||
|
<StatusPill tone="success">员工账号</StatusPill>
|
||||||
|
<StatusPill>{roleNames(user.roles)}</StatusPill>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="info-list">
|
||||||
|
<div>
|
||||||
|
<UserRound aria-hidden size={18} />
|
||||||
|
<span>所属门店</span>
|
||||||
|
<strong>{user.storeName || "未绑定门店"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ShieldCheck aria-hidden size={18} />
|
||||||
|
<span>权限数量</span>
|
||||||
|
<strong>{user.permissions.includes("*") ? "全部权限" : `${user.permissions.length} 项`}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<KeyRound aria-hidden size={18} />
|
||||||
|
<span>修改密码</span>
|
||||||
|
<strong>旧密码 + 新密码</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="section-block">
|
||||||
|
<div className="section-title">
|
||||||
|
<h2>账号安全</h2>
|
||||||
|
</div>
|
||||||
|
<p className="muted-copy">本应用不展示明文密码,也不会在前端持久化 token 或密码。</p>
|
||||||
|
<PasswordChangeForm />
|
||||||
|
</section>
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { FilterNav } from "@/components/filter-nav";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { StatusPill } from "@/components/status-pill";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
import { getShifts } from "@/lib/mobile-data";
|
||||||
|
import type { Route } from "next";
|
||||||
|
|
||||||
|
type ScheduleRange = "today" | "upcoming" | "all";
|
||||||
|
|
||||||
|
type SchedulePageProps = {
|
||||||
|
searchParams: Promise<{ range?: string | string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText = {
|
||||||
|
scheduled: "已排班",
|
||||||
|
cancelled: "已取消",
|
||||||
|
completed: "已完成"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function firstSearchValue(value: string | string[] | undefined) {
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateString(date: Date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRange(value: string | string[] | undefined): ScheduleRange {
|
||||||
|
const range = firstSearchValue(value);
|
||||||
|
if (range === "upcoming" || range === "all") return range;
|
||||||
|
return "today";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeFilter(range: ScheduleRange) {
|
||||||
|
const today = toDateString(new Date());
|
||||||
|
if (range === "today") return { startDate: today, endDate: today };
|
||||||
|
if (range === "upcoming") return { startDate: today };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyCopy(range: ScheduleRange) {
|
||||||
|
if (range === "today") return "今天没有安排班次。";
|
||||||
|
if (range === "upcoming") return "当前没有未来班次。";
|
||||||
|
return "当前没有你的排班记录。";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SchedulePage({ searchParams }: SchedulePageProps) {
|
||||||
|
const range = normalizeRange((await searchParams).range);
|
||||||
|
const shifts = await getShifts(rangeFilter(range));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<PageHeader title="排班" description="查看本周和未来班次,第一版仅支持查看。" />
|
||||||
|
<FilterNav
|
||||||
|
label="排班筛选"
|
||||||
|
options={[
|
||||||
|
{ label: "今日", href: "/schedule" as Route, active: range === "today" },
|
||||||
|
{ label: "未来", href: "/schedule?range=upcoming" as Route, active: range === "upcoming" },
|
||||||
|
{ label: "全部", href: "/schedule?range=all" as Route, active: range === "all" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{shifts.length > 0 ? (
|
||||||
|
<section className="list-stack">
|
||||||
|
{shifts.map((shift) => (
|
||||||
|
<article className="list-card" key={shift.id}>
|
||||||
|
<div>
|
||||||
|
<h2>{shift.position}</h2>
|
||||||
|
<p>
|
||||||
|
{formatDateTime(shift.startAt)} - {formatDateTime(shift.endAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill tone={shift.status === "cancelled" ? "danger" : "success"}>
|
||||||
|
{statusText[shift.status]}
|
||||||
|
</StatusPill>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="暂无排班" description={emptyCopy(range)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { MapPin, Phone, Store } from "lucide-react";
|
||||||
|
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { StatusPill } from "@/components/status-pill";
|
||||||
|
import { getCurrentStore, getCurrentStoreEmployees, getCurrentUser, getPermissionPayload } from "@/lib/mobile-data";
|
||||||
|
|
||||||
|
export default async function StorePage() {
|
||||||
|
const [user, permissions] = await Promise.all([getCurrentUser(), getPermissionPayload()]);
|
||||||
|
const canViewStoreEmployees =
|
||||||
|
permissions.permissions.includes("*") ||
|
||||||
|
permissions.permissions.includes("employee:view:store") ||
|
||||||
|
permissions.permissions.includes("employee:view:all");
|
||||||
|
const [store, employees] = await Promise.all([
|
||||||
|
getCurrentStore(user),
|
||||||
|
canViewStoreEmployees ? getCurrentStoreEmployees(user) : Promise.resolve([])
|
||||||
|
]);
|
||||||
|
const storeName = store?.name || user.storeName || "未绑定门店";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<PageHeader title="门店" description="当前登录员工所属门店信息。" />
|
||||||
|
<section className="store-panel">
|
||||||
|
<Store aria-hidden size={28} />
|
||||||
|
<div>
|
||||||
|
<h2>{storeName}</h2>
|
||||||
|
<p>门店 ID:{store?.id ?? user.storeId ?? "暂无"}</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill tone={store?.status === "INACTIVE" ? "warning" : "success"}>
|
||||||
|
{store?.status === "INACTIVE" ? "停用" : "正常"}
|
||||||
|
</StatusPill>
|
||||||
|
</section>
|
||||||
|
<section className="info-list">
|
||||||
|
<div>
|
||||||
|
<MapPin aria-hidden size={18} />
|
||||||
|
<span>地址</span>
|
||||||
|
<strong>{store?.address || "暂无"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Phone aria-hidden size={18} />
|
||||||
|
<span>电话</span>
|
||||||
|
<strong>{store?.phone || "暂无"}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{canViewStoreEmployees && employees.length > 0 ? (
|
||||||
|
<section className="section-block">
|
||||||
|
<div className="section-title">
|
||||||
|
<h2>本店员工</h2>
|
||||||
|
</div>
|
||||||
|
<div className="list-stack">
|
||||||
|
{employees.map((employee) => (
|
||||||
|
<article className="list-card" key={employee.id}>
|
||||||
|
<div>
|
||||||
|
<h3>{employee.name}</h3>
|
||||||
|
<p>{employee.phone || employee.roles.map((role) => role.name).join("、") || "未分配角色"}</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill tone={employee.status === "INACTIVE" ? "warning" : "success"}>
|
||||||
|
{employee.status === "INACTIVE" ? "停用" : "在职"}
|
||||||
|
</StatusPill>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : canViewStoreEmployees ? (
|
||||||
|
<EmptyState title="暂无本店员工" description="有查看权限,但当前接口没有返回员工数据。" />
|
||||||
|
) : (
|
||||||
|
<EmptyState title="仅展示门店资料" description="当前账号没有查看本店员工列表的权限。" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ArrowLeft, CalendarClock, ClipboardList, Store, UsersRound } from "lucide-react";
|
||||||
|
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { StatusPill } from "@/components/status-pill";
|
||||||
|
import { TaskActionPanel } from "@/components/task-action-panel";
|
||||||
|
import { BackendError } from "@/lib/backend";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
import { getTaskDetail } from "@/lib/mobile-data";
|
||||||
|
import type { TaskEvent, TaskStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
type TaskDetailPageProps = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText: Record<TaskStatus, string> = {
|
||||||
|
pending: "待处理",
|
||||||
|
in_progress: "处理中",
|
||||||
|
completed: "已完成",
|
||||||
|
cancelled: "已取消"
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusTone: Record<TaskStatus, "default" | "success" | "warning" | "danger"> = {
|
||||||
|
pending: "warning",
|
||||||
|
in_progress: "warning",
|
||||||
|
completed: "success",
|
||||||
|
cancelled: "danger"
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventText: Record<string, string> = {
|
||||||
|
CREATED: "创建任务",
|
||||||
|
UPDATED: "更新任务",
|
||||||
|
STARTED: "开始处理",
|
||||||
|
COMPLETED: "完成任务",
|
||||||
|
CANCELLED: "取消任务",
|
||||||
|
COMMENTED: "追加备注"
|
||||||
|
};
|
||||||
|
|
||||||
|
function eventDescription(event: TaskEvent) {
|
||||||
|
const label = eventText[event.eventType] ?? event.eventType;
|
||||||
|
if (event.comment) return `${label}:${event.comment}`;
|
||||||
|
if (event.fromStatus && event.toStatus) return `${label}:${event.fromStatus} -> ${event.toStatus}`;
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
let task: Awaited<ReturnType<typeof getTaskDetail>>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
task = await getTaskDetail(id);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BackendError && error.status === 404) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Link className="inline-link" href="/tasks">
|
||||||
|
<ArrowLeft aria-hidden size={16} />
|
||||||
|
返回任务
|
||||||
|
</Link>
|
||||||
|
<PageHeader eyebrow={task.storeName || "任务详情"} title={task.title} description={task.description} />
|
||||||
|
<section className="detail-card">
|
||||||
|
<div className="detail-meta">
|
||||||
|
<span>
|
||||||
|
<ClipboardList aria-hidden size={16} />
|
||||||
|
当前状态
|
||||||
|
</span>
|
||||||
|
<StatusPill tone={statusTone[task.status]}>{statusText[task.status]}</StatusPill>
|
||||||
|
</div>
|
||||||
|
<div className="info-list compact">
|
||||||
|
<div>
|
||||||
|
<CalendarClock aria-hidden size={18} />
|
||||||
|
<span>截止时间</span>
|
||||||
|
<strong>{formatDateTime(task.dueAt)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Store aria-hidden size={18} />
|
||||||
|
<span>所属门店</span>
|
||||||
|
<strong>{task.storeName || "未指定"}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<UsersRound aria-hidden size={18} />
|
||||||
|
<span>处理人</span>
|
||||||
|
<strong>{task.assignees.map((item) => item.name).join("、") || task.assigneeName || "未指定"}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<TaskActionPanel initialStatus={task.status} taskId={task.id} />
|
||||||
|
<section className="section-block">
|
||||||
|
<div className="section-title">
|
||||||
|
<h2>处理记录</h2>
|
||||||
|
</div>
|
||||||
|
{task.events.length > 0 ? (
|
||||||
|
<div className="timeline">
|
||||||
|
{task.events.map((event) => (
|
||||||
|
<article className="timeline-item" key={event.id}>
|
||||||
|
<strong>{eventDescription(event)}</strong>
|
||||||
|
<span>{formatDateTime(event.createdAt)}</span>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted-copy">暂无处理记录。</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { Route } from "next";
|
||||||
|
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { FilterNav } from "@/components/filter-nav";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { StatusPill } from "@/components/status-pill";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
import { getTasks } from "@/lib/mobile-data";
|
||||||
|
import type { TaskStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
const statusText: Record<TaskStatus, string> = {
|
||||||
|
pending: "待处理",
|
||||||
|
in_progress: "处理中",
|
||||||
|
completed: "已完成",
|
||||||
|
cancelled: "已取消"
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusTone: Record<TaskStatus, "success" | "warning" | "danger"> = {
|
||||||
|
pending: "warning",
|
||||||
|
in_progress: "warning",
|
||||||
|
completed: "success",
|
||||||
|
cancelled: "danger"
|
||||||
|
};
|
||||||
|
|
||||||
|
type TasksPageProps = {
|
||||||
|
searchParams: Promise<{ status?: string | string[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function firstSearchValue(value: string | string[] | undefined) {
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(value: string | string[] | undefined): TaskStatus | "all" {
|
||||||
|
const status = firstSearchValue(value);
|
||||||
|
if (status === "pending" || status === "in_progress" || status === "completed" || status === "cancelled") {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyCopy(status: TaskStatus | "all") {
|
||||||
|
if (status === "pending") return "当前没有待处理任务。";
|
||||||
|
if (status === "in_progress") return "当前没有处理中的任务。";
|
||||||
|
if (status === "completed") return "当前没有已完成任务。";
|
||||||
|
if (status === "cancelled") return "当前没有已取消任务。";
|
||||||
|
return "当前没有分配给你的任务。";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TasksPage({ searchParams }: TasksPageProps) {
|
||||||
|
const status = normalizeStatus((await searchParams).status);
|
||||||
|
const tasks = await getTasks(status === "all" ? {} : { status });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack">
|
||||||
|
<PageHeader title="任务" description="查看个人和门店分配给你的事项。" />
|
||||||
|
<FilterNav
|
||||||
|
label="任务筛选"
|
||||||
|
options={[
|
||||||
|
{ label: "全部", href: "/tasks" as Route, active: status === "all" },
|
||||||
|
{ label: "待处理", href: "/tasks?status=pending" as Route, active: status === "pending" },
|
||||||
|
{ label: "处理中", href: "/tasks?status=in_progress" as Route, active: status === "in_progress" },
|
||||||
|
{ label: "已完成", href: "/tasks?status=completed" as Route, active: status === "completed" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{tasks.length > 0 ? (
|
||||||
|
<section className="list-stack">
|
||||||
|
{tasks.map((task) => {
|
||||||
|
const href = `/tasks/${task.id}` as Route;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link className="list-card" href={href} key={task.id}>
|
||||||
|
<div>
|
||||||
|
<h2>{task.title}</h2>
|
||||||
|
<p>{task.description || `截止:${formatDateTime(task.dueAt)}`}</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill tone={statusTone[task.status]}>{statusText[task.status]}</StatusPill>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="暂无任务" description={emptyCopy(status)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { LoginForm } from "@/components/login-form";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<main className="login-page">
|
||||||
|
<section className="login-hero">
|
||||||
|
<div className="login-brand-line">
|
||||||
|
<span className="brand-mark" aria-hidden />
|
||||||
|
<span>Role User</span>
|
||||||
|
</div>
|
||||||
|
<p className="hero-kicker">Employee Console</p>
|
||||||
|
<h1>门店工作,清晰处理</h1>
|
||||||
|
<p>排班、任务、公告和账号安全集中在同一个移动端工作区。</p>
|
||||||
|
<div className="login-preview" aria-hidden>
|
||||||
|
<div className="login-preview__top">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
<div className="login-preview__body">
|
||||||
|
<strong>08:30</strong>
|
||||||
|
<span>早班 · 正常</span>
|
||||||
|
</div>
|
||||||
|
<div className="login-preview__grid">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="login-panel" aria-label="员工登录">
|
||||||
|
<div className="login-panel__header">
|
||||||
|
<h2>员工登录</h2>
|
||||||
|
<p>使用手机号和临时密码进入工作台。</p>
|
||||||
|
</div>
|
||||||
|
<LoginForm />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { setSessionToken } from "@/lib/session";
|
||||||
|
import { BackendError, backendRequest } from "@/lib/backend";
|
||||||
|
import type { LoginResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const result = await backendRequest<LoginResponse>("/auth/employee/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: body.username,
|
||||||
|
password: body.password
|
||||||
|
}),
|
||||||
|
token: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
await setSessionToken(result.token);
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: result.user,
|
||||||
|
tokenType: result.tokenType,
|
||||||
|
expiresIn: result.expiresIn
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const status = error instanceof BackendError ? error.status : 500;
|
||||||
|
const message = error instanceof Error ? error.message : "登录失败";
|
||||||
|
|
||||||
|
return Response.json({ success: false, data: null, message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { clearSessionToken } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
await clearSessionToken();
|
||||||
|
return Response.json({ success: true, data: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
await clearSessionToken();
|
||||||
|
const next = request.nextUrl.searchParams.get("next") || "/login";
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL(next, request.url));
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { proxyBackendJson } from "@/lib/backend";
|
||||||
|
import type { AuthUser } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
const body = await request.text();
|
||||||
|
|
||||||
|
return proxyBackendJson<AuthUser>("/auth/me/password", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: body || undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { proxyBackendJson } from "@/lib/backend";
|
||||||
|
import type { AuthUser } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return proxyBackendJson<AuthUser>("/auth/me");
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { proxyBackendJson } from "@/lib/backend";
|
||||||
|
import type { AnnouncementDetail } from "@/lib/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(_request: Request, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyBackendJson<AnnouncementDetail>(`/mobile/announcements/${encodeURIComponent(id)}/read`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { proxyBackendJson } from "@/lib/backend";
|
||||||
|
import type { AnnouncementDetail } from "@/lib/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyBackendJson<AnnouncementDetail>(`/mobile/announcements/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { proxyBackendJson } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const search = new URL(request.url).search;
|
||||||
|
return proxyBackendJson<unknown>(`/mobile/announcements${search}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { proxyBackendJson } from "@/lib/backend";
|
||||||
|
import type { ShiftSummary } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const search = new URL(request.url).search;
|
||||||
|
return proxyBackendJson<ShiftSummary[]>(`/mobile/shifts${search}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { proxyBackendJson } from "@/lib/backend";
|
||||||
|
import type { ShiftSummary } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return proxyBackendJson<ShiftSummary | null>("/mobile/shifts/today");
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { backendErrorResponse, BackendError, backendRequest } from "@/lib/backend";
|
||||||
|
import type { ApiEnvelope, AuthUser, StoreEmployee } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await backendRequest<AuthUser>("/auth/me");
|
||||||
|
|
||||||
|
if (!user.storeId) {
|
||||||
|
return Response.json({ success: true, data: [] } satisfies ApiEnvelope<StoreEmployee[]>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = new URLSearchParams({
|
||||||
|
storeId: String(user.storeId),
|
||||||
|
page: "1",
|
||||||
|
pageSize: "100"
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const employees = await backendRequest<unknown>(`/employees?${search.toString()}`);
|
||||||
|
return Response.json({ success: true, data: employees } satisfies ApiEnvelope<unknown>);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BackendError && [403, 404].includes(error.status)) {
|
||||||
|
return Response.json({ success: true, data: [] } satisfies ApiEnvelope<StoreEmployee[]>);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return backendErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { backendErrorResponse, BackendError, backendRequest } from "@/lib/backend";
|
||||||
|
import type { ApiEnvelope, AuthUser, StoreDetail } from "@/lib/types";
|
||||||
|
|
||||||
|
function fallbackStore(user: AuthUser): StoreDetail | null {
|
||||||
|
if (!user.storeId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.storeId,
|
||||||
|
name: user.storeName ?? "当前门店",
|
||||||
|
address: null,
|
||||||
|
phone: null,
|
||||||
|
status: undefined,
|
||||||
|
employees: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await backendRequest<AuthUser>("/auth/me");
|
||||||
|
|
||||||
|
if (!user.storeId) {
|
||||||
|
return Response.json({ success: true, data: null } satisfies ApiEnvelope<StoreDetail | null>);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = await backendRequest<StoreDetail>(`/stores/${user.storeId}`);
|
||||||
|
return Response.json({ success: true, data: store } satisfies ApiEnvelope<StoreDetail>);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BackendError && [403, 404].includes(error.status)) {
|
||||||
|
return Response.json({ success: true, data: fallbackStore(user) } satisfies ApiEnvelope<StoreDetail | null>);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return backendErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { proxyBackendJson } from "@/lib/backend";
|
||||||
|
import type { TaskDetail } from "@/lib/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: Request, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.text();
|
||||||
|
|
||||||
|
return proxyBackendJson<TaskDetail>(`/mobile/tasks/${encodeURIComponent(id)}/comment`, {
|
||||||
|
method: "POST",
|
||||||
|
body: body || undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { proxyBackendJson } from "@/lib/backend";
|
||||||
|
import type { TaskDetail } from "@/lib/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(_request: Request, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyBackendJson<TaskDetail>(`/mobile/tasks/${encodeURIComponent(id)}/complete`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { proxyBackendJson } from "@/lib/backend";
|
||||||
|
import type { TaskDetail } from "@/lib/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyBackendJson<TaskDetail>(`/mobile/tasks/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { proxyBackendJson } from "@/lib/backend";
|
||||||
|
import type { TaskDetail } from "@/lib/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(_request: Request, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyBackendJson<TaskDetail>(`/mobile/tasks/${encodeURIComponent(id)}/start`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { proxyBackendJson } from "@/lib/backend";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const search = new URL(request.url).search;
|
||||||
|
return proxyBackendJson<unknown>(`/mobile/tasks${search}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { proxyBackendJson } from "@/lib/backend";
|
||||||
|
import type { PermissionPayload } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return proxyBackendJson<PermissionPayload>("/permissions/me");
|
||||||
|
}
|
||||||
+1109
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "员工工作台",
|
||||||
|
description: "门店员工日常任务、排班、公告和个人中心",
|
||||||
|
manifest: "/manifest.webmanifest",
|
||||||
|
icons: {
|
||||||
|
icon: "/icon.svg",
|
||||||
|
apple: "/icon.svg"
|
||||||
|
},
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
title: "员工工作台",
|
||||||
|
statusBarStyle: "default"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
themeColor: "#ffffff"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: "role-user 员工工作台",
|
||||||
|
short_name: "员工工作台",
|
||||||
|
description: "门店员工任务、公告、排班和个人中心",
|
||||||
|
start_url: "/dashboard",
|
||||||
|
scope: "/",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: "#f4f4f6",
|
||||||
|
theme_color: "#ffffff",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "/icon.svg",
|
||||||
|
sizes: "any",
|
||||||
|
type: "image/svg+xml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
type AnnouncementReadMarkerProps = {
|
||||||
|
id: string;
|
||||||
|
read: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AnnouncementReadMarker({ id, read }: AnnouncementReadMarkerProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const sentRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (read || sentRef.current) return;
|
||||||
|
|
||||||
|
sentRef.current = true;
|
||||||
|
void fetch(`/api/mobile/announcements/${encodeURIComponent(id)}/read`, { method: "POST" }).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [id, read, router]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Bell, CalendarDays, ClipboardList, Home, UserRound } from "lucide-react";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ href: "/dashboard", label: "工作台", icon: Home },
|
||||||
|
{ href: "/tasks", label: "任务", icon: ClipboardList },
|
||||||
|
{ href: "/schedule", label: "排班", icon: CalendarDays },
|
||||||
|
{ href: "/announcements", label: "公告", icon: Bell },
|
||||||
|
{ href: "/me", label: "我的", icon: UserRound }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function BottomNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="bottom-nav" aria-label="主要导航">
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link className={`bottom-nav__item${active ? " is-active" : ""}`} href={item.href} key={item.href}>
|
||||||
|
<Icon aria-hidden size={20} strokeWidth={active ? 2.6 : 2} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Inbox } from "lucide-react";
|
||||||
|
|
||||||
|
type EmptyStateProps = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmptyState({ title, description }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<section className="empty-state">
|
||||||
|
<Inbox aria-hidden size={28} />
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{description}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { Route } from "next";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
type FilterNavOption = {
|
||||||
|
href: Route;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FilterNavProps = {
|
||||||
|
label: string;
|
||||||
|
options: FilterNavOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FilterNav({ label, options }: FilterNavProps) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label={label}
|
||||||
|
className="segmented"
|
||||||
|
style={{ "--segment-count": options.length } as CSSProperties}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<Link
|
||||||
|
aria-current={option.active ? "page" : undefined}
|
||||||
|
className={option.active ? "is-active" : undefined}
|
||||||
|
href={option.href}
|
||||||
|
key={option.label}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Eye, EyeOff, LockKeyhole, Smartphone } from "lucide-react";
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setPending(true);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const response = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: String(formData.get("username") || "").trim(),
|
||||||
|
password: String(formData.get("password") || "")
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||||
|
setError(payload?.message || "登录失败,请检查手机号和密码");
|
||||||
|
setPending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="login-form" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
<span>手机号</span>
|
||||||
|
<div className="input-wrap">
|
||||||
|
<Smartphone aria-hidden size={18} />
|
||||||
|
<input
|
||||||
|
autoComplete="username"
|
||||||
|
defaultValue="13211111111"
|
||||||
|
inputMode="tel"
|
||||||
|
name="username"
|
||||||
|
placeholder="请输入员工手机号"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>密码</span>
|
||||||
|
<div className="input-wrap">
|
||||||
|
<LockKeyhole aria-hidden size={18} />
|
||||||
|
<input
|
||||||
|
autoComplete="current-password"
|
||||||
|
defaultValue="pw111111"
|
||||||
|
name="password"
|
||||||
|
placeholder="请输入登录密码"
|
||||||
|
required
|
||||||
|
type={passwordVisible ? "text" : "password"}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label={passwordVisible ? "隐藏密码" : "显示密码"}
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() => setPasswordVisible((visible) => !visible)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{passwordVisible ? <EyeOff aria-hidden size={18} /> : <Eye aria-hidden size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
<button className="primary-button" disabled={pending} type="submit">
|
||||||
|
{pending ? "登录中..." : "登录"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function LogoutButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
setPending(true);
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
router.replace("/login");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className="secondary-button danger" disabled={pending} onClick={logout} type="button">
|
||||||
|
<LogOut aria-hidden size={18} />
|
||||||
|
{pending ? "退出中..." : "退出登录"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
type PageHeaderProps = {
|
||||||
|
eyebrow?: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageHeader({ eyebrow, title, description }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="page-header">
|
||||||
|
{eyebrow ? <p className="eyebrow">{eyebrow}</p> : null}
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{description ? <p>{description}</p> : null}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { KeyRound } from "lucide-react";
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
|
||||||
|
import type { ApiEnvelope } from "@/lib/types";
|
||||||
|
|
||||||
|
async function readError(response: Response) {
|
||||||
|
const payload = (await response.json().catch(() => null)) as Partial<ApiEnvelope<unknown>> | null;
|
||||||
|
return payload?.message || "修改密码失败";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordChangeForm() {
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
setPending(true);
|
||||||
|
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const oldPassword = String(formData.get("oldPassword") || "");
|
||||||
|
const newPassword = String(formData.get("newPassword") || "");
|
||||||
|
const confirmPassword = String(formData.get("confirmPassword") || "");
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError("两次输入的新密码不一致");
|
||||||
|
setPending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/auth/me/password", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ oldPassword, newPassword })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(await readError(response));
|
||||||
|
setPending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
setMessage("密码已修改");
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="form-stack" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
<span>旧密码</span>
|
||||||
|
<div className="input-wrap">
|
||||||
|
<KeyRound aria-hidden size={18} />
|
||||||
|
<input autoComplete="current-password" minLength={8} name="oldPassword" required type="password" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>新密码</span>
|
||||||
|
<div className="input-wrap">
|
||||||
|
<KeyRound aria-hidden size={18} />
|
||||||
|
<input autoComplete="new-password" minLength={8} name="newPassword" required type="password" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>确认新密码</span>
|
||||||
|
<div className="input-wrap">
|
||||||
|
<KeyRound aria-hidden size={18} />
|
||||||
|
<input autoComplete="new-password" minLength={8} name="confirmPassword" required type="password" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
{message ? <p className="form-success">{message}</p> : null}
|
||||||
|
<button className="primary-button" disabled={pending} type="submit">
|
||||||
|
<KeyRound aria-hidden size={18} />
|
||||||
|
{pending ? "修改中..." : "修改密码"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
type StatusPillProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
tone?: "default" | "success" | "warning" | "danger";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusPill({ children, tone = "default" }: StatusPillProps) {
|
||||||
|
return <span className={`status-pill status-pill--${tone}`}>{children}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { CheckCircle2, MessageSquarePlus, PlayCircle } from "lucide-react";
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
|
||||||
|
import type { ApiEnvelope, TaskDetail, TaskStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
type TaskActionPanelProps = {
|
||||||
|
taskId: string;
|
||||||
|
initialStatus: TaskStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskAction = "start" | "complete" | "comment";
|
||||||
|
|
||||||
|
function normalizeStatus(value: unknown): TaskStatus | null {
|
||||||
|
switch (value) {
|
||||||
|
case "PENDING":
|
||||||
|
case "pending":
|
||||||
|
return "pending";
|
||||||
|
case "IN_PROGRESS":
|
||||||
|
case "in_progress":
|
||||||
|
return "in_progress";
|
||||||
|
case "COMPLETED":
|
||||||
|
case "completed":
|
||||||
|
return "completed";
|
||||||
|
case "CANCELLED":
|
||||||
|
case "cancelled":
|
||||||
|
return "cancelled";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMessage(response: Response) {
|
||||||
|
const payload = (await response.json().catch(() => null)) as Partial<ApiEnvelope<unknown>> | null;
|
||||||
|
return payload?.message || "操作失败";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskActionPanel({ taskId, initialStatus }: TaskActionPanelProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [status, setStatus] = useState(initialStatus);
|
||||||
|
const [pendingAction, setPendingAction] = useState<TaskAction | null>(null);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function runAction(action: Exclude<TaskAction, "comment">) {
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
setPendingAction(action);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/mobile/tasks/${encodeURIComponent(taskId)}/${action}`, { method: "POST" });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(await readMessage(response));
|
||||||
|
setPendingAction(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as ApiEnvelope<TaskDetail> | null;
|
||||||
|
const nextStatus = normalizeStatus(payload?.data?.status);
|
||||||
|
if (nextStatus) setStatus(nextStatus);
|
||||||
|
|
||||||
|
setMessage(action === "start" ? "任务已开始处理" : "任务已完成");
|
||||||
|
setPendingAction(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitComment(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
setPendingAction("comment");
|
||||||
|
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const comment = String(formData.get("comment") || "").trim();
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
setError("请填写备注内容");
|
||||||
|
setPendingAction(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/mobile/tasks/${encodeURIComponent(taskId)}/comment`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ comment })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(await readMessage(response));
|
||||||
|
setPendingAction(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
setMessage("备注已追加");
|
||||||
|
setPendingAction(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const canStart = status === "pending";
|
||||||
|
const canComplete = status === "pending" || status === "in_progress";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section-block">
|
||||||
|
<div className="section-title">
|
||||||
|
<h2>处理任务</h2>
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={!canStart || pendingAction !== null}
|
||||||
|
onClick={() => void runAction("start")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<PlayCircle aria-hidden size={18} />
|
||||||
|
{pendingAction === "start" ? "开始中..." : "开始处理"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={!canComplete || pendingAction !== null}
|
||||||
|
onClick={() => void runAction("complete")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<CheckCircle2 aria-hidden size={18} />
|
||||||
|
{pendingAction === "complete" ? "完成中..." : "完成任务"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form className="form-stack" onSubmit={submitComment}>
|
||||||
|
<label>
|
||||||
|
<span>处理备注</span>
|
||||||
|
<textarea className="text-area" maxLength={1000} name="comment" placeholder="填写交接、处理进展或完成说明" />
|
||||||
|
</label>
|
||||||
|
<button className="secondary-button" disabled={pendingAction !== null} type="submit">
|
||||||
|
<MessageSquarePlus aria-hidden size={18} />
|
||||||
|
{pendingAction === "comment" ? "提交中..." : "追加备注"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
{message ? <p className="form-success">{message}</p> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { clearSessionToken, getSessionToken } from "@/lib/session";
|
||||||
|
import type { ApiEnvelope } from "@/lib/types";
|
||||||
|
|
||||||
|
export class BackendError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public status: number
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackendBaseUrl() {
|
||||||
|
return process.env.ACCESS_MANAGE_API_BASE_URL || "http://localhost:3500/api";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUrl(path: string) {
|
||||||
|
return new URL(path.replace(/^\//, ""), `${getBackendBaseUrl().replace(/\/$/, "")}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseBackendResponse<T>(response: Response): Promise<T> {
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
const payload = contentType.includes("application/json") ? await response.json() : null;
|
||||||
|
|
||||||
|
if (!response.ok || payload?.success === false) {
|
||||||
|
throw new BackendError(payload?.message || response.statusText || "请求失败", response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload && typeof payload === "object" && "data" in payload) {
|
||||||
|
return payload.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function backendRequest<T>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit & { token?: string; allowUnauthorized?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const token = init.token ?? (await getSessionToken());
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
|
||||||
|
headers.set("Accept", "application/json");
|
||||||
|
if (init.body && !headers.has("Content-Type")) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(toUrl(path), {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseBackendResponse<T>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireBackendData<T>(path: string) {
|
||||||
|
try {
|
||||||
|
return await backendRequest<T>(path);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BackendError && error.status === 401) {
|
||||||
|
redirect("/api/auth/logout?next=/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function proxyBackendJson<T>(path: string, init: RequestInit = {}) {
|
||||||
|
try {
|
||||||
|
const data = await backendRequest<T>(path, init);
|
||||||
|
return Response.json({ success: true, data } satisfies ApiEnvelope<T>);
|
||||||
|
} catch (error) {
|
||||||
|
return backendErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function backendErrorResponse(error: unknown, fallbackMessage = "请求失败") {
|
||||||
|
const status = error instanceof BackendError ? error.status : 500;
|
||||||
|
const message = error instanceof Error ? error.message : fallbackMessage;
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
await clearSessionToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ success: false, data: null, message }, { status });
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) return "暂未设置";
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roleNames(roles: { name: string }[]) {
|
||||||
|
return roles.map((role) => role.name).join("、") || "未分配角色";
|
||||||
|
}
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { BackendError, backendRequest, requireBackendData } from "@/lib/backend";
|
||||||
|
import type {
|
||||||
|
AnnouncementDetail,
|
||||||
|
AnnouncementSummary,
|
||||||
|
AuthUser,
|
||||||
|
MobileBootstrap,
|
||||||
|
PermissionPayload,
|
||||||
|
ShiftSummary,
|
||||||
|
StoreDetail,
|
||||||
|
StoreEmployee,
|
||||||
|
TaskAssignee,
|
||||||
|
TaskDetail,
|
||||||
|
TaskEvent,
|
||||||
|
TaskSummary
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
const EMPTY_TASKS: TaskSummary[] = [];
|
||||||
|
const EMPTY_ANNOUNCEMENTS: AnnouncementSummary[] = [];
|
||||||
|
const EMPTY_SHIFTS: ShiftSummary[] = [];
|
||||||
|
const OPTIONAL_MOBILE_FALLBACK_STATUSES = [404, 500, 501, 502, 503];
|
||||||
|
const OPTIONAL_STORE_FALLBACK_STATUSES = [403, 404, 500, 501, 502, 503];
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type TaskListFilter = {
|
||||||
|
status?: TaskSummary["status"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShiftListFilter = {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is UnknownRecord {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringOrUndefined(value: unknown) {
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrUndefined(value: unknown) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericIdOrUndefined(value: unknown) {
|
||||||
|
if (typeof value === "number" && Number.isInteger(value)) return value;
|
||||||
|
if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toList<T>(value: unknown, normalize: (item: unknown) => T | null): T[] {
|
||||||
|
const source = Array.isArray(value) ? value : isRecord(value) && Array.isArray(value.items) ? value.items : [];
|
||||||
|
return source.map(normalize).filter((item): item is T => item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTaskStatus(value: unknown): TaskSummary["status"] {
|
||||||
|
switch (value) {
|
||||||
|
case "PENDING":
|
||||||
|
case "pending":
|
||||||
|
return "pending";
|
||||||
|
case "IN_PROGRESS":
|
||||||
|
case "in_progress":
|
||||||
|
return "in_progress";
|
||||||
|
case "COMPLETED":
|
||||||
|
case "completed":
|
||||||
|
return "completed";
|
||||||
|
case "CANCELLED":
|
||||||
|
case "cancelled":
|
||||||
|
return "cancelled";
|
||||||
|
default:
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTaskPriority(value: unknown): TaskSummary["priority"] {
|
||||||
|
switch (value) {
|
||||||
|
case "LOW":
|
||||||
|
case "low":
|
||||||
|
return "low";
|
||||||
|
case "HIGH":
|
||||||
|
case "high":
|
||||||
|
return "high";
|
||||||
|
case "URGENT":
|
||||||
|
case "urgent":
|
||||||
|
return "urgent";
|
||||||
|
default:
|
||||||
|
return "normal";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAnnouncementLevel(value: unknown): AnnouncementSummary["level"] {
|
||||||
|
switch (value) {
|
||||||
|
case "IMPORTANT":
|
||||||
|
case "important":
|
||||||
|
return "important";
|
||||||
|
case "URGENT":
|
||||||
|
case "urgent":
|
||||||
|
return "urgent";
|
||||||
|
default:
|
||||||
|
return "normal";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeShiftStatus(value: unknown): ShiftSummary["status"] {
|
||||||
|
switch (value) {
|
||||||
|
case "CANCELLED":
|
||||||
|
case "cancelled":
|
||||||
|
return "cancelled";
|
||||||
|
case "COMPLETED":
|
||||||
|
case "completed":
|
||||||
|
return "completed";
|
||||||
|
default:
|
||||||
|
return "scheduled";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryFromContent(value: unknown) {
|
||||||
|
const content = stringOrUndefined(value);
|
||||||
|
if (!content) return undefined;
|
||||||
|
|
||||||
|
const text = content.replace(/\s+/g, " ").trim();
|
||||||
|
return text.length > 48 ? `${text.slice(0, 48)}...` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTask(value: unknown): TaskSummary | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
|
||||||
|
const title = stringOrUndefined(value.title) ?? "未命名任务";
|
||||||
|
const assignees = toList(value.assignees, normalizeTaskAssignee);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(value.id ?? title),
|
||||||
|
title,
|
||||||
|
description: stringOrUndefined(value.description),
|
||||||
|
status: normalizeTaskStatus(value.status),
|
||||||
|
priority: normalizeTaskPriority(value.priority),
|
||||||
|
dueAt: stringOrUndefined(value.dueAt) ?? null,
|
||||||
|
assigneeName: stringOrUndefined(value.assigneeName) ?? assignees[0]?.name,
|
||||||
|
storeName: stringOrUndefined(value.storeName)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTaskAssignee(value: unknown): TaskAssignee | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
|
||||||
|
const id = numericIdOrUndefined(value.id);
|
||||||
|
const name = stringOrUndefined(value.name) ?? stringOrUndefined(value.displayName);
|
||||||
|
if (id === undefined || !name) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
phone: stringOrUndefined(value.phone) ?? stringOrUndefined(value.username)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTaskEvent(value: unknown): TaskEvent | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
|
||||||
|
const id = numericIdOrUndefined(value.id);
|
||||||
|
const eventType = stringOrUndefined(value.eventType);
|
||||||
|
if (id === undefined || !eventType) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
taskId: numericIdOrUndefined(value.taskId),
|
||||||
|
eventType,
|
||||||
|
fromStatus: stringOrUndefined(value.fromStatus) ?? null,
|
||||||
|
toStatus: stringOrUndefined(value.toStatus) ?? null,
|
||||||
|
comment: stringOrUndefined(value.comment) ?? null,
|
||||||
|
createdAt: stringOrUndefined(value.createdAt) ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTaskDetail(value: unknown): TaskDetail {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new BackendError("任务详情数据格式不正确", 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = normalizeTask(value);
|
||||||
|
if (!summary) {
|
||||||
|
throw new BackendError("任务详情数据格式不正确", 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
assignees: toList(value.assignees, normalizeTaskAssignee),
|
||||||
|
events: toList(value.events, normalizeTaskEvent),
|
||||||
|
createdAt: stringOrUndefined(value.createdAt) ?? null,
|
||||||
|
updatedAt: stringOrUndefined(value.updatedAt) ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAnnouncement(value: unknown): AnnouncementSummary | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
|
||||||
|
const title = stringOrUndefined(value.title) ?? "未命名公告";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(value.id ?? title),
|
||||||
|
title,
|
||||||
|
summary: stringOrUndefined(value.summary) ?? summaryFromContent(value.content),
|
||||||
|
level: normalizeAnnouncementLevel(value.level),
|
||||||
|
publishedAt: stringOrUndefined(value.publishedAt) ?? null,
|
||||||
|
read: typeof value.read === "boolean" ? value.read : Boolean(value.readAt)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAnnouncementDetail(value: unknown): AnnouncementDetail {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new BackendError("公告详情数据格式不正确", 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = normalizeAnnouncement(value);
|
||||||
|
if (!summary) {
|
||||||
|
throw new BackendError("公告详情数据格式不正确", 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
content: stringOrUndefined(value.content) ?? summary.summary ?? "",
|
||||||
|
readAt: stringOrUndefined(value.readAt) ?? null,
|
||||||
|
createdAt: stringOrUndefined(value.createdAt) ?? null,
|
||||||
|
updatedAt: stringOrUndefined(value.updatedAt) ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeShift(value: unknown): ShiftSummary | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
|
||||||
|
const startAt = stringOrUndefined(value.startAt) ?? "";
|
||||||
|
const endAt = stringOrUndefined(value.endAt) ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(value.id ?? startAt),
|
||||||
|
date: stringOrUndefined(value.date) ?? startAt.slice(0, 10),
|
||||||
|
position: stringOrUndefined(value.position) ?? stringOrUndefined(value.roleName) ?? "班次",
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
status: normalizeShiftStatus(value.status)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstShift(value: unknown) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return normalizeShift(value[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeShift(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftList(value: unknown) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(normalizeShift).filter((item): item is ShiftSummary => item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shift = normalizeShift(value);
|
||||||
|
return shift ? [shift] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionsFromBootstrap(value: unknown) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((item): item is string => typeof item === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(value)) {
|
||||||
|
const codes = Array.isArray(value.codes) ? value.codes : value.permissions;
|
||||||
|
return Array.isArray(codes) ? codes.filter((item): item is string => typeof item === "string") : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRole(value: unknown) {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
|
||||||
|
const id = numericIdOrUndefined(value.id);
|
||||||
|
const code = stringOrUndefined(value.code);
|
||||||
|
const name = stringOrUndefined(value.name);
|
||||||
|
|
||||||
|
if (id === undefined || !code || !name) return null;
|
||||||
|
|
||||||
|
return { id, code, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStoreEmployee(value: unknown): StoreEmployee | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
|
||||||
|
const id = numericIdOrUndefined(value.id);
|
||||||
|
const name = stringOrUndefined(value.name) ?? stringOrUndefined(value.displayName);
|
||||||
|
if (id === undefined || !name) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
phone: stringOrUndefined(value.phone) ?? stringOrUndefined(value.username),
|
||||||
|
status: stringOrUndefined(value.status),
|
||||||
|
roles: toList(value.roles, normalizeRole)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStoreDetail(value: unknown, fallbackUser?: AuthUser): StoreDetail | null {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
if (!fallbackUser?.storeId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: fallbackUser.storeId,
|
||||||
|
name: fallbackUser.storeName ?? "当前门店",
|
||||||
|
address: null,
|
||||||
|
phone: null,
|
||||||
|
status: undefined,
|
||||||
|
employees: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = numericIdOrUndefined(value.id) ?? fallbackUser?.storeId;
|
||||||
|
const name = stringOrUndefined(value.name) ?? fallbackUser?.storeName;
|
||||||
|
if (id === undefined || !name) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
address: stringOrUndefined(value.address) ?? null,
|
||||||
|
phone: stringOrUndefined(value.phone) ?? null,
|
||||||
|
status: stringOrUndefined(value.status),
|
||||||
|
employees: toList(value.employees, normalizeStoreEmployee)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBootstrap(data: unknown): MobileBootstrap {
|
||||||
|
if (!isRecord(data) || !isRecord(data.user)) {
|
||||||
|
throw new BackendError("员工端首屏数据格式不正确", 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
const counters = isRecord(data.counters) ? data.counters : {};
|
||||||
|
const tasks = toList(data.tasks, normalizeTask).slice(0, 5);
|
||||||
|
const latestAnnouncements = toList(data.latestAnnouncements, normalizeAnnouncement).slice(0, 3);
|
||||||
|
const todayShifts = shiftList(data.todayShifts ?? data.todayShift);
|
||||||
|
const todayShift = firstShift(data.todayShift) ?? todayShifts[0] ?? null;
|
||||||
|
const pendingTasks = tasks.filter((task) => task.status === "pending" || task.status === "in_progress");
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: data.user as AuthUser,
|
||||||
|
permissions: permissionsFromBootstrap(data.permissions),
|
||||||
|
unreadAnnouncementCount:
|
||||||
|
numberOrUndefined(data.unreadAnnouncementCount ?? counters.unreadAnnouncementCount) ??
|
||||||
|
latestAnnouncements.filter((item) => !item.read).length,
|
||||||
|
pendingTaskCount: numberOrUndefined(data.pendingTaskCount ?? counters.pendingTaskCount) ?? pendingTasks.length,
|
||||||
|
overdueTaskCount:
|
||||||
|
numberOrUndefined(data.overdueTaskCount ?? counters.overdueTaskCount) ??
|
||||||
|
pendingTasks.filter((task) => task.dueAt && new Date(task.dueAt).getTime() < now).length,
|
||||||
|
todayShift,
|
||||||
|
todayShifts,
|
||||||
|
latestAnnouncements,
|
||||||
|
tasks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optionalBackendData<T>(path: string, fallback: T, fallbackStatuses = [404]) {
|
||||||
|
try {
|
||||||
|
return await backendRequest<T>(path);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BackendError && fallbackStatuses.includes(error.status)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSearch(path: string, entries: Record<string, string | undefined>) {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(entries)) {
|
||||||
|
if (value) {
|
||||||
|
search.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = search.toString();
|
||||||
|
return query ? `${path}?${query}` : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskStatusToBackend(status?: TaskSummary["status"]) {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "PENDING";
|
||||||
|
case "in_progress":
|
||||||
|
return "IN_PROGRESS";
|
||||||
|
case "completed":
|
||||||
|
return "COMPLETED";
|
||||||
|
case "cancelled":
|
||||||
|
return "CANCELLED";
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBootstrapData(): Promise<MobileBootstrap> {
|
||||||
|
try {
|
||||||
|
return normalizeBootstrap(await requireBackendData<unknown>("/mobile/bootstrap"));
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof BackendError && OPTIONAL_MOBILE_FALLBACK_STATUSES.includes(error.status))) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user, permissionPayload, tasks, announcements, todayShift] = await Promise.all([
|
||||||
|
requireBackendData<AuthUser>("/auth/me"),
|
||||||
|
requireBackendData<PermissionPayload>("/permissions/me"),
|
||||||
|
optionalBackendData<unknown>("/mobile/tasks", EMPTY_TASKS, OPTIONAL_MOBILE_FALLBACK_STATUSES),
|
||||||
|
optionalBackendData<unknown>("/mobile/announcements", EMPTY_ANNOUNCEMENTS, OPTIONAL_MOBILE_FALLBACK_STATUSES),
|
||||||
|
optionalBackendData<unknown>("/mobile/shifts/today", null, OPTIONAL_MOBILE_FALLBACK_STATUSES)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const normalizedTasks = toList(tasks, normalizeTask);
|
||||||
|
const normalizedAnnouncements = toList(announcements, normalizeAnnouncement);
|
||||||
|
const todayShifts = shiftList(todayShift);
|
||||||
|
const pendingTasks = normalizedTasks.filter((task) => task.status === "pending" || task.status === "in_progress");
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
permissions: permissionPayload.permissions,
|
||||||
|
unreadAnnouncementCount: normalizedAnnouncements.filter((item) => !item.read).length,
|
||||||
|
pendingTaskCount: pendingTasks.length,
|
||||||
|
overdueTaskCount: pendingTasks.filter((task) => task.dueAt && new Date(task.dueAt).getTime() < now).length,
|
||||||
|
todayShift: todayShifts[0] ?? null,
|
||||||
|
todayShifts,
|
||||||
|
latestAnnouncements: normalizedAnnouncements.slice(0, 3),
|
||||||
|
tasks: normalizedTasks.slice(0, 5)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUser() {
|
||||||
|
return requireBackendData<AuthUser>("/auth/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPermissionPayload() {
|
||||||
|
return requireBackendData<PermissionPayload>("/permissions/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTasks(filter: TaskListFilter = {}) {
|
||||||
|
const path = withSearch("/mobile/tasks", {
|
||||||
|
status: taskStatusToBackend(filter.status)
|
||||||
|
});
|
||||||
|
|
||||||
|
return toList(
|
||||||
|
await optionalBackendData<unknown>(path, EMPTY_TASKS, OPTIONAL_MOBILE_FALLBACK_STATUSES),
|
||||||
|
normalizeTask
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskDetail(id: string) {
|
||||||
|
return normalizeTaskDetail(await requireBackendData<unknown>(`/mobile/tasks/${encodeURIComponent(id)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnnouncements() {
|
||||||
|
return toList(
|
||||||
|
await optionalBackendData<unknown>("/mobile/announcements", EMPTY_ANNOUNCEMENTS, OPTIONAL_MOBILE_FALLBACK_STATUSES),
|
||||||
|
normalizeAnnouncement
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnnouncementDetail(id: string) {
|
||||||
|
return normalizeAnnouncementDetail(
|
||||||
|
await requireBackendData<unknown>(`/mobile/announcements/${encodeURIComponent(id)}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getShifts(filter: ShiftListFilter = {}) {
|
||||||
|
const path = withSearch("/mobile/shifts", {
|
||||||
|
startDate: filter.startDate,
|
||||||
|
endDate: filter.endDate
|
||||||
|
});
|
||||||
|
|
||||||
|
return toList(
|
||||||
|
await optionalBackendData<unknown>(path, EMPTY_SHIFTS, OPTIONAL_MOBILE_FALLBACK_STATUSES),
|
||||||
|
normalizeShift
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentStore(user?: AuthUser) {
|
||||||
|
const currentUser = user ?? (await getCurrentUser());
|
||||||
|
if (!currentUser.storeId) return null;
|
||||||
|
|
||||||
|
const fallback = normalizeStoreDetail(null, currentUser);
|
||||||
|
return normalizeStoreDetail(
|
||||||
|
await optionalBackendData<unknown>(`/stores/${currentUser.storeId}`, fallback, OPTIONAL_STORE_FALLBACK_STATUSES),
|
||||||
|
currentUser
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentStoreEmployees(user?: AuthUser) {
|
||||||
|
const currentUser = user ?? (await getCurrentUser());
|
||||||
|
if (!currentUser.storeId) return [];
|
||||||
|
|
||||||
|
const search = new URLSearchParams({
|
||||||
|
storeId: String(currentUser.storeId),
|
||||||
|
page: "1",
|
||||||
|
pageSize: "100"
|
||||||
|
});
|
||||||
|
|
||||||
|
return toList(
|
||||||
|
await optionalBackendData<unknown>(`/employees?${search.toString()}`, [], OPTIONAL_STORE_FALLBACK_STATUSES),
|
||||||
|
normalizeStoreEmployee
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const DEFAULT_COOKIE_NAME = "role_user_session";
|
||||||
|
|
||||||
|
export function getSessionCookieName() {
|
||||||
|
return process.env.ROLE_USER_SESSION_COOKIE || DEFAULT_COOKIE_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionToken() {
|
||||||
|
return (await cookies()).get(getSessionCookieName())?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSessionToken(token: string) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
cookieStore.set(getSessionCookieName(), token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 8
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSessionToken() {
|
||||||
|
(await cookies()).delete(getSessionCookieName());
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
export type AccountType = "SUPER_ADMIN" | "EMPLOYEE";
|
||||||
|
|
||||||
|
export type Role = {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
accountType: AccountType;
|
||||||
|
storeId?: number;
|
||||||
|
storeName?: string;
|
||||||
|
roles: Role[];
|
||||||
|
permissions: string[];
|
||||||
|
canManage: boolean;
|
||||||
|
lastLoginAt?: string | null;
|
||||||
|
mustChangePassword?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PermissionMenu = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
permission: string;
|
||||||
|
actions: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PermissionPayload = {
|
||||||
|
permissions: string[];
|
||||||
|
menus: PermissionMenu[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiEnvelope<T> = {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginResponse = {
|
||||||
|
token: string;
|
||||||
|
tokenType: "Bearer";
|
||||||
|
expiresIn: string;
|
||||||
|
user: AuthUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskStatus = "pending" | "in_progress" | "completed" | "cancelled";
|
||||||
|
|
||||||
|
export type TaskSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
priority: "low" | "normal" | "high" | "urgent";
|
||||||
|
dueAt?: string | null;
|
||||||
|
assigneeName?: string;
|
||||||
|
storeName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskAssignee = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskEvent = {
|
||||||
|
id: number;
|
||||||
|
taskId?: number;
|
||||||
|
eventType: "CREATED" | "UPDATED" | "STARTED" | "COMPLETED" | "CANCELLED" | "COMMENTED" | string;
|
||||||
|
fromStatus?: string | null;
|
||||||
|
toStatus?: string | null;
|
||||||
|
comment?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskDetail = TaskSummary & {
|
||||||
|
assignees: TaskAssignee[];
|
||||||
|
events: TaskEvent[];
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnnouncementSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
level: "normal" | "important" | "urgent";
|
||||||
|
publishedAt?: string | null;
|
||||||
|
read: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnnouncementDetail = AnnouncementSummary & {
|
||||||
|
content: string;
|
||||||
|
readAt?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShiftSummary = {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
position: string;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string;
|
||||||
|
status: "scheduled" | "cancelled" | "completed";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreEmployee = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
status?: string;
|
||||||
|
roles: Role[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreDetail = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
address?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
status?: string;
|
||||||
|
employees: StoreEmployee[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MobileBootstrap = {
|
||||||
|
user: AuthUser;
|
||||||
|
permissions: string[];
|
||||||
|
unreadAnnouncementCount: number;
|
||||||
|
pendingTaskCount: number;
|
||||||
|
overdueTaskCount: number;
|
||||||
|
todayShift: ShiftSummary | null;
|
||||||
|
todayShifts: ShiftSummary[];
|
||||||
|
latestAnnouncements: AnnouncementSummary[];
|
||||||
|
tasks: TaskSummary[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const protectedRoutes = ["/dashboard", "/tasks", "/schedule", "/announcements", "/store", "/me"];
|
||||||
|
const publicRoutes = ["/", "/login"];
|
||||||
|
const cookieName = process.env.ROLE_USER_SESSION_COOKIE || "role_user_session";
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
const path = request.nextUrl.pathname;
|
||||||
|
const hasSession = Boolean(request.cookies.get(cookieName)?.value);
|
||||||
|
const isProtected = protectedRoutes.some((route) => path === route || path.startsWith(`${route}/`));
|
||||||
|
const isPublic = publicRoutes.includes(path);
|
||||||
|
|
||||||
|
if (isProtected && !hasSession) {
|
||||||
|
return NextResponse.redirect(new URL("/login", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPublic && hasSession) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!api|_next/static|_next/image|manifest.webmanifest|sw.js|.*\\..*).*)"]
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user