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
+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|.*\\..*).*)"]
};