Initial role user app

This commit is contained in:
湛兮
2026-06-02 14:46:39 +08:00
commit 003dc60111
62 changed files with 7835 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
ACCESS_MANAGE_API_BASE_URL=http://localhost:3500/api
ROLE_USER_SESSION_COOKIE=role_user_session
+10
View File
@@ -0,0 +1,10 @@
.next
node_modules
out
dist
.env
.env*.local
*.log
tsconfig.tsbuildinfo
.playwright-mcp
role-user-*.png
+100
View File
@@ -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`
- 公告、任务、排班已按正式后端接口接入列表、筛选、详情和员工端操作;后端临时不可用时页面保留业务空态。
+14
View File
@@ -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.
+134
View File
@@ -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 后清理会话并回到登录页。
- 密码不会出现在前端持久化存储中。
- 没有任何明文密码查询或展示接口。
- 管理后台重置出的临时密码仅显示一次,并有审计记录。
+209
View File
@@ -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. 不存在明文密码查看接口。
+11
View File
@@ -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;
+6
View File
@@ -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.
+32
View File
@@ -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;
+27
View File
@@ -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"
}
}
+3746
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -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

+7
View File
@@ -0,0 +1,7 @@
self.addEventListener("install", function () {
self.skipWaiting();
});
self.addEventListener("activate", function (event) {
event.waitUntil(self.clients.claim());
});
+68
View File
@@ -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>
);
}
+92
View File
@@ -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>
);
}
+168
View File
@@ -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>
);
}
+10
View File
@@ -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>
);
}
+10
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}
+71
View File
@@ -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>
);
}
+114
View File
@@ -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>
);
}
+88
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+33
View File
@@ -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 });
}
}
+15
View File
@@ -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));
}
+11
View File
@@ -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
});
}
+6
View File
@@ -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}`);
}
+7
View File
@@ -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}`);
}
+6
View File
@@ -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);
}
}
+38
View File
@@ -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"
});
}
+11
View File
@@ -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"
});
}
+6
View File
@@ -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}`);
}
+6
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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>
);
}
+21
View File
@@ -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"
}
]
};
}
+5
View File
@@ -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;
}
+33
View File
@@ -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>
);
}
+16
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+83
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+15
View File
@@ -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>
);
}
+84
View File
@@ -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>
);
}
+8
View File
@@ -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>;
}
+145
View File
@@ -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>
);
}
+94
View File
@@ -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 });
}
+14
View File
@@ -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("、") || "未分配角色";
}
+512
View File
@@ -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
);
}
+29
View File
@@ -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());
}
+136
View File
@@ -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[];
};
+26
View File
@@ -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|.*\\..*).*)"]
};
+34
View File
@@ -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"]
}