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
+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[];
};