Initial role user app
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, Bell } from "lucide-react";
|
||||
|
||||
import { AnnouncementReadMarker } from "@/components/announcement-read-marker";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { StatusPill } from "@/components/status-pill";
|
||||
import { BackendError } from "@/lib/backend";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { getAnnouncementDetail } from "@/lib/mobile-data";
|
||||
import type { AnnouncementSummary } from "@/lib/types";
|
||||
|
||||
type AnnouncementDetailPageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
const levelText: Record<AnnouncementSummary["level"], string> = {
|
||||
normal: "普通",
|
||||
important: "重要",
|
||||
urgent: "紧急"
|
||||
};
|
||||
|
||||
const levelTone: Record<AnnouncementSummary["level"], "default" | "warning" | "danger"> = {
|
||||
normal: "default",
|
||||
important: "warning",
|
||||
urgent: "danger"
|
||||
};
|
||||
|
||||
export default async function AnnouncementDetailPage({ params }: AnnouncementDetailPageProps) {
|
||||
const { id } = await params;
|
||||
let announcement: Awaited<ReturnType<typeof getAnnouncementDetail>>;
|
||||
|
||||
try {
|
||||
announcement = await getAnnouncementDetail(id);
|
||||
} catch (error) {
|
||||
if (error instanceof BackendError && error.status === 404) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Link className="inline-link" href="/announcements">
|
||||
<ArrowLeft aria-hidden size={16} />
|
||||
返回公告
|
||||
</Link>
|
||||
<PageHeader
|
||||
eyebrow={formatDateTime(announcement.publishedAt)}
|
||||
title={announcement.title}
|
||||
description={announcement.summary}
|
||||
/>
|
||||
<AnnouncementReadMarker id={announcement.id} read={announcement.read} />
|
||||
<section className="detail-card">
|
||||
<div className="detail-meta">
|
||||
<span>
|
||||
<Bell aria-hidden size={16} />
|
||||
公告级别
|
||||
</span>
|
||||
<StatusPill tone={levelTone[announcement.level]}>{levelText[announcement.level]}</StatusPill>
|
||||
<StatusPill tone={announcement.read ? "default" : "warning"}>{announcement.read ? "已读" : "未读"}</StatusPill>
|
||||
</div>
|
||||
<article className="long-copy">{announcement.content}</article>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { FilterNav } from "@/components/filter-nav";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { StatusPill } from "@/components/status-pill";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { getAnnouncements } from "@/lib/mobile-data";
|
||||
|
||||
type AnnouncementFilter = "all" | "unread" | "read";
|
||||
|
||||
type AnnouncementsPageProps = {
|
||||
searchParams: Promise<{ read?: string | string[] }>;
|
||||
};
|
||||
|
||||
function firstSearchValue(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function normalizeFilter(value: string | string[] | undefined): AnnouncementFilter {
|
||||
const read = firstSearchValue(value);
|
||||
if (read === "unread" || read === "read") return read;
|
||||
return "all";
|
||||
}
|
||||
|
||||
function emptyCopy(filter: AnnouncementFilter) {
|
||||
if (filter === "unread") {
|
||||
return {
|
||||
title: "暂无未读公告",
|
||||
description: "当前没有需要你阅读的新公告。"
|
||||
};
|
||||
}
|
||||
|
||||
if (filter === "read") {
|
||||
return {
|
||||
title: "暂无已读公告",
|
||||
description: "你阅读过的公告会保留在这里,方便回看。"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "暂无公告",
|
||||
description: "当前没有面向你的门店公告。"
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AnnouncementsPage({ searchParams }: AnnouncementsPageProps) {
|
||||
const filter = normalizeFilter((await searchParams).read);
|
||||
const allAnnouncements = await getAnnouncements();
|
||||
const announcements = allAnnouncements.filter((item) => {
|
||||
if (filter === "unread") return !item.read;
|
||||
if (filter === "read") return item.read;
|
||||
return true;
|
||||
});
|
||||
const empty = emptyCopy(filter);
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<PageHeader title="公告" description="接收管理员或店长发布给你的门店通知。" />
|
||||
<FilterNav
|
||||
label="公告筛选"
|
||||
options={[
|
||||
{ label: "全部", href: "/announcements" as Route, active: filter === "all" },
|
||||
{ label: "未读", href: "/announcements?read=unread" as Route, active: filter === "unread" },
|
||||
{ label: "已读", href: "/announcements?read=read" as Route, active: filter === "read" }
|
||||
]}
|
||||
/>
|
||||
{announcements.length > 0 ? (
|
||||
<section className="list-stack">
|
||||
{announcements.map((item) => {
|
||||
const href = `/announcements/${item.id}` as Route;
|
||||
|
||||
return (
|
||||
<Link className="list-card" href={href} key={item.id}>
|
||||
<div>
|
||||
<h2>{item.title}</h2>
|
||||
<p>{item.summary || formatDateTime(item.publishedAt)}</p>
|
||||
</div>
|
||||
<StatusPill tone={item.level === "normal" ? "default" : "warning"}>
|
||||
{item.read ? "已读" : "未读"}
|
||||
</StatusPill>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
) : (
|
||||
<EmptyState title={empty.title} description={empty.description} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { Bell, CalendarDays, ChevronRight, ClipboardList } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { StatusPill } from "@/components/status-pill";
|
||||
import { formatDateTime, roleNames } from "@/lib/format";
|
||||
import { getBootstrapData } from "@/lib/mobile-data";
|
||||
import type { TaskStatus } from "@/lib/types";
|
||||
|
||||
const taskStatusText: Record<TaskStatus, string> = {
|
||||
pending: "待处理",
|
||||
in_progress: "处理中",
|
||||
completed: "已完成",
|
||||
cancelled: "已取消"
|
||||
};
|
||||
|
||||
const taskStatusTone: Record<TaskStatus, "default" | "success" | "warning" | "danger"> = {
|
||||
pending: "default",
|
||||
in_progress: "warning",
|
||||
completed: "success",
|
||||
cancelled: "danger"
|
||||
};
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const data = await getBootstrapData();
|
||||
const { user } = data;
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="dashboard-hero">
|
||||
<div className="dashboard-hero__content">
|
||||
<p className="hero-kicker">{user.storeName || "员工端"}</p>
|
||||
<h1>你好,{user.displayName}</h1>
|
||||
<p>今日工作状态已同步,先处理最重要的事项。</p>
|
||||
</div>
|
||||
<div className="hero-stat-strip" aria-label="今日概览">
|
||||
<span>
|
||||
<strong>{data.pendingTaskCount}</strong>
|
||||
待办
|
||||
</span>
|
||||
<span>
|
||||
<strong>{data.todayShifts.length}</strong>
|
||||
班次
|
||||
</span>
|
||||
<span>
|
||||
<strong>{data.unreadAnnouncementCount}</strong>
|
||||
未读
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="identity-card">
|
||||
<div className="identity-card__media">
|
||||
<span className="avatar">{user.displayName.slice(0, 1)}</span>
|
||||
</div>
|
||||
<div className="identity-card__body">
|
||||
<h2>{user.displayName}</h2>
|
||||
<p>{user.storeName || "未绑定门店"}</p>
|
||||
<div className="pill-row">
|
||||
<StatusPill tone="success">账号正常</StatusPill>
|
||||
<StatusPill>{roleNames(user.roles)}</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="metric-grid">
|
||||
<Link className="metric-tile" href="/tasks">
|
||||
<ClipboardList aria-hidden size={20} />
|
||||
<span>{data.pendingTaskCount}</span>
|
||||
<p>待办任务</p>
|
||||
</Link>
|
||||
<Link className="metric-tile" href="/tasks">
|
||||
<Bell aria-hidden size={20} />
|
||||
<span>{data.overdueTaskCount}</span>
|
||||
<p>已逾期</p>
|
||||
</Link>
|
||||
<Link className="metric-tile" href="/announcements">
|
||||
<Bell aria-hidden size={20} />
|
||||
<span>{data.unreadAnnouncementCount}</span>
|
||||
<p>未读公告</p>
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title">
|
||||
<h2>今日排班</h2>
|
||||
<CalendarDays aria-hidden size={20} />
|
||||
</div>
|
||||
{data.todayShifts.length > 0 ? (
|
||||
<div className="list-stack">
|
||||
{data.todayShifts.map((shift) => (
|
||||
<article className="list-card" key={shift.id}>
|
||||
<div>
|
||||
<h3>{shift.position}</h3>
|
||||
<p>
|
||||
{formatDateTime(shift.startAt)} - {formatDateTime(shift.endAt)}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill tone="success">已排班</StatusPill>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="今日暂无排班" description="今天没有安排班次,可在排班页查看未来排班。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title">
|
||||
<h2>待办任务</h2>
|
||||
<Link href="/tasks">
|
||||
查看全部
|
||||
<ChevronRight aria-hidden size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
{data.tasks.length > 0 ? (
|
||||
<div className="list-stack">
|
||||
{data.tasks.map((task) => {
|
||||
const href = `/tasks/${task.id}` as Route;
|
||||
|
||||
return (
|
||||
<Link className="list-card" href={href} key={task.id}>
|
||||
<div>
|
||||
<h3>{task.title}</h3>
|
||||
<p>{task.description || `截止:${formatDateTime(task.dueAt)}`}</p>
|
||||
</div>
|
||||
<StatusPill tone={taskStatusTone[task.status]}>{taskStatusText[task.status]}</StatusPill>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="暂无待办任务" description="待处理和处理中任务会显示在这里。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title">
|
||||
<h2>最新公告</h2>
|
||||
<Link href="/announcements">
|
||||
查看全部
|
||||
<ChevronRight aria-hidden size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
{data.latestAnnouncements.length > 0 ? (
|
||||
<div className="list-stack">
|
||||
{data.latestAnnouncements.map((item) => {
|
||||
const href = `/announcements/${item.id}` as Route;
|
||||
|
||||
return (
|
||||
<Link className="list-card" href={href} key={item.id}>
|
||||
<div>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.summary || formatDateTime(item.publishedAt)}</p>
|
||||
</div>
|
||||
<StatusPill tone={item.read ? "default" : "warning"}>{item.read ? "已读" : "未读"}</StatusPill>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="暂无公告" description="当前没有面向你的门店公告。" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { BottomNav } from "@/components/bottom-nav";
|
||||
|
||||
export default function AppLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<main className="app-main">{children}</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<div className="skeleton skeleton-title" />
|
||||
<div className="skeleton skeleton-card" />
|
||||
<div className="skeleton skeleton-card" />
|
||||
<div className="skeleton skeleton-card" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { KeyRound, ShieldCheck, UserRound } from "lucide-react";
|
||||
|
||||
import { LogoutButton } from "@/components/logout-button";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { PasswordChangeForm } from "@/components/password-change-form";
|
||||
import { StatusPill } from "@/components/status-pill";
|
||||
import { roleNames } from "@/lib/format";
|
||||
import { getCurrentUser } from "@/lib/mobile-data";
|
||||
|
||||
export default async function MePage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<PageHeader title="我的" description="个人资料、账号安全和退出登录。" />
|
||||
<section className="profile-card">
|
||||
<span className="avatar large">{user.displayName.slice(0, 1)}</span>
|
||||
<h2>{user.displayName}</h2>
|
||||
<p>{user.username}</p>
|
||||
<div className="pill-row">
|
||||
<StatusPill tone="success">员工账号</StatusPill>
|
||||
<StatusPill>{roleNames(user.roles)}</StatusPill>
|
||||
</div>
|
||||
</section>
|
||||
<section className="info-list">
|
||||
<div>
|
||||
<UserRound aria-hidden size={18} />
|
||||
<span>所属门店</span>
|
||||
<strong>{user.storeName || "未绑定门店"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<ShieldCheck aria-hidden size={18} />
|
||||
<span>权限数量</span>
|
||||
<strong>{user.permissions.includes("*") ? "全部权限" : `${user.permissions.length} 项`}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<KeyRound aria-hidden size={18} />
|
||||
<span>修改密码</span>
|
||||
<strong>旧密码 + 新密码</strong>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<div className="section-title">
|
||||
<h2>账号安全</h2>
|
||||
</div>
|
||||
<p className="muted-copy">本应用不展示明文密码,也不会在前端持久化 token 或密码。</p>
|
||||
<PasswordChangeForm />
|
||||
</section>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { FilterNav } from "@/components/filter-nav";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { StatusPill } from "@/components/status-pill";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { getShifts } from "@/lib/mobile-data";
|
||||
import type { Route } from "next";
|
||||
|
||||
type ScheduleRange = "today" | "upcoming" | "all";
|
||||
|
||||
type SchedulePageProps = {
|
||||
searchParams: Promise<{ range?: string | string[] }>;
|
||||
};
|
||||
|
||||
const statusText = {
|
||||
scheduled: "已排班",
|
||||
cancelled: "已取消",
|
||||
completed: "已完成"
|
||||
} as const;
|
||||
|
||||
function firstSearchValue(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function toDateString(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function normalizeRange(value: string | string[] | undefined): ScheduleRange {
|
||||
const range = firstSearchValue(value);
|
||||
if (range === "upcoming" || range === "all") return range;
|
||||
return "today";
|
||||
}
|
||||
|
||||
function rangeFilter(range: ScheduleRange) {
|
||||
const today = toDateString(new Date());
|
||||
if (range === "today") return { startDate: today, endDate: today };
|
||||
if (range === "upcoming") return { startDate: today };
|
||||
return {};
|
||||
}
|
||||
|
||||
function emptyCopy(range: ScheduleRange) {
|
||||
if (range === "today") return "今天没有安排班次。";
|
||||
if (range === "upcoming") return "当前没有未来班次。";
|
||||
return "当前没有你的排班记录。";
|
||||
}
|
||||
|
||||
export default async function SchedulePage({ searchParams }: SchedulePageProps) {
|
||||
const range = normalizeRange((await searchParams).range);
|
||||
const shifts = await getShifts(rangeFilter(range));
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<PageHeader title="排班" description="查看本周和未来班次,第一版仅支持查看。" />
|
||||
<FilterNav
|
||||
label="排班筛选"
|
||||
options={[
|
||||
{ label: "今日", href: "/schedule" as Route, active: range === "today" },
|
||||
{ label: "未来", href: "/schedule?range=upcoming" as Route, active: range === "upcoming" },
|
||||
{ label: "全部", href: "/schedule?range=all" as Route, active: range === "all" }
|
||||
]}
|
||||
/>
|
||||
{shifts.length > 0 ? (
|
||||
<section className="list-stack">
|
||||
{shifts.map((shift) => (
|
||||
<article className="list-card" key={shift.id}>
|
||||
<div>
|
||||
<h2>{shift.position}</h2>
|
||||
<p>
|
||||
{formatDateTime(shift.startAt)} - {formatDateTime(shift.endAt)}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill tone={shift.status === "cancelled" ? "danger" : "success"}>
|
||||
{statusText[shift.status]}
|
||||
</StatusPill>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<EmptyState title="暂无排班" description={emptyCopy(range)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { MapPin, Phone, Store } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { StatusPill } from "@/components/status-pill";
|
||||
import { getCurrentStore, getCurrentStoreEmployees, getCurrentUser, getPermissionPayload } from "@/lib/mobile-data";
|
||||
|
||||
export default async function StorePage() {
|
||||
const [user, permissions] = await Promise.all([getCurrentUser(), getPermissionPayload()]);
|
||||
const canViewStoreEmployees =
|
||||
permissions.permissions.includes("*") ||
|
||||
permissions.permissions.includes("employee:view:store") ||
|
||||
permissions.permissions.includes("employee:view:all");
|
||||
const [store, employees] = await Promise.all([
|
||||
getCurrentStore(user),
|
||||
canViewStoreEmployees ? getCurrentStoreEmployees(user) : Promise.resolve([])
|
||||
]);
|
||||
const storeName = store?.name || user.storeName || "未绑定门店";
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<PageHeader title="门店" description="当前登录员工所属门店信息。" />
|
||||
<section className="store-panel">
|
||||
<Store aria-hidden size={28} />
|
||||
<div>
|
||||
<h2>{storeName}</h2>
|
||||
<p>门店 ID:{store?.id ?? user.storeId ?? "暂无"}</p>
|
||||
</div>
|
||||
<StatusPill tone={store?.status === "INACTIVE" ? "warning" : "success"}>
|
||||
{store?.status === "INACTIVE" ? "停用" : "正常"}
|
||||
</StatusPill>
|
||||
</section>
|
||||
<section className="info-list">
|
||||
<div>
|
||||
<MapPin aria-hidden size={18} />
|
||||
<span>地址</span>
|
||||
<strong>{store?.address || "暂无"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<Phone aria-hidden size={18} />
|
||||
<span>电话</span>
|
||||
<strong>{store?.phone || "暂无"}</strong>
|
||||
</div>
|
||||
</section>
|
||||
{canViewStoreEmployees && employees.length > 0 ? (
|
||||
<section className="section-block">
|
||||
<div className="section-title">
|
||||
<h2>本店员工</h2>
|
||||
</div>
|
||||
<div className="list-stack">
|
||||
{employees.map((employee) => (
|
||||
<article className="list-card" key={employee.id}>
|
||||
<div>
|
||||
<h3>{employee.name}</h3>
|
||||
<p>{employee.phone || employee.roles.map((role) => role.name).join("、") || "未分配角色"}</p>
|
||||
</div>
|
||||
<StatusPill tone={employee.status === "INACTIVE" ? "warning" : "success"}>
|
||||
{employee.status === "INACTIVE" ? "停用" : "在职"}
|
||||
</StatusPill>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : canViewStoreEmployees ? (
|
||||
<EmptyState title="暂无本店员工" description="有查看权限,但当前接口没有返回员工数据。" />
|
||||
) : (
|
||||
<EmptyState title="仅展示门店资料" description="当前账号没有查看本店员工列表的权限。" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, CalendarClock, ClipboardList, Store, UsersRound } from "lucide-react";
|
||||
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { StatusPill } from "@/components/status-pill";
|
||||
import { TaskActionPanel } from "@/components/task-action-panel";
|
||||
import { BackendError } from "@/lib/backend";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { getTaskDetail } from "@/lib/mobile-data";
|
||||
import type { TaskEvent, TaskStatus } from "@/lib/types";
|
||||
|
||||
type TaskDetailPageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
const statusText: Record<TaskStatus, string> = {
|
||||
pending: "待处理",
|
||||
in_progress: "处理中",
|
||||
completed: "已完成",
|
||||
cancelled: "已取消"
|
||||
};
|
||||
|
||||
const statusTone: Record<TaskStatus, "default" | "success" | "warning" | "danger"> = {
|
||||
pending: "warning",
|
||||
in_progress: "warning",
|
||||
completed: "success",
|
||||
cancelled: "danger"
|
||||
};
|
||||
|
||||
const eventText: Record<string, string> = {
|
||||
CREATED: "创建任务",
|
||||
UPDATED: "更新任务",
|
||||
STARTED: "开始处理",
|
||||
COMPLETED: "完成任务",
|
||||
CANCELLED: "取消任务",
|
||||
COMMENTED: "追加备注"
|
||||
};
|
||||
|
||||
function eventDescription(event: TaskEvent) {
|
||||
const label = eventText[event.eventType] ?? event.eventType;
|
||||
if (event.comment) return `${label}:${event.comment}`;
|
||||
if (event.fromStatus && event.toStatus) return `${label}:${event.fromStatus} -> ${event.toStatus}`;
|
||||
return label;
|
||||
}
|
||||
|
||||
export default async function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
const { id } = await params;
|
||||
let task: Awaited<ReturnType<typeof getTaskDetail>>;
|
||||
|
||||
try {
|
||||
task = await getTaskDetail(id);
|
||||
} catch (error) {
|
||||
if (error instanceof BackendError && error.status === 404) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Link className="inline-link" href="/tasks">
|
||||
<ArrowLeft aria-hidden size={16} />
|
||||
返回任务
|
||||
</Link>
|
||||
<PageHeader eyebrow={task.storeName || "任务详情"} title={task.title} description={task.description} />
|
||||
<section className="detail-card">
|
||||
<div className="detail-meta">
|
||||
<span>
|
||||
<ClipboardList aria-hidden size={16} />
|
||||
当前状态
|
||||
</span>
|
||||
<StatusPill tone={statusTone[task.status]}>{statusText[task.status]}</StatusPill>
|
||||
</div>
|
||||
<div className="info-list compact">
|
||||
<div>
|
||||
<CalendarClock aria-hidden size={18} />
|
||||
<span>截止时间</span>
|
||||
<strong>{formatDateTime(task.dueAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<Store aria-hidden size={18} />
|
||||
<span>所属门店</span>
|
||||
<strong>{task.storeName || "未指定"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<UsersRound aria-hidden size={18} />
|
||||
<span>处理人</span>
|
||||
<strong>{task.assignees.map((item) => item.name).join("、") || task.assigneeName || "未指定"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<TaskActionPanel initialStatus={task.status} taskId={task.id} />
|
||||
<section className="section-block">
|
||||
<div className="section-title">
|
||||
<h2>处理记录</h2>
|
||||
</div>
|
||||
{task.events.length > 0 ? (
|
||||
<div className="timeline">
|
||||
{task.events.map((event) => (
|
||||
<article className="timeline-item" key={event.id}>
|
||||
<strong>{eventDescription(event)}</strong>
|
||||
<span>{formatDateTime(event.createdAt)}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted-copy">暂无处理记录。</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { FilterNav } from "@/components/filter-nav";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { StatusPill } from "@/components/status-pill";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { getTasks } from "@/lib/mobile-data";
|
||||
import type { TaskStatus } from "@/lib/types";
|
||||
|
||||
const statusText: Record<TaskStatus, string> = {
|
||||
pending: "待处理",
|
||||
in_progress: "处理中",
|
||||
completed: "已完成",
|
||||
cancelled: "已取消"
|
||||
};
|
||||
|
||||
const statusTone: Record<TaskStatus, "success" | "warning" | "danger"> = {
|
||||
pending: "warning",
|
||||
in_progress: "warning",
|
||||
completed: "success",
|
||||
cancelled: "danger"
|
||||
};
|
||||
|
||||
type TasksPageProps = {
|
||||
searchParams: Promise<{ status?: string | string[] }>;
|
||||
};
|
||||
|
||||
function firstSearchValue(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function normalizeStatus(value: string | string[] | undefined): TaskStatus | "all" {
|
||||
const status = firstSearchValue(value);
|
||||
if (status === "pending" || status === "in_progress" || status === "completed" || status === "cancelled") {
|
||||
return status;
|
||||
}
|
||||
|
||||
return "all";
|
||||
}
|
||||
|
||||
function emptyCopy(status: TaskStatus | "all") {
|
||||
if (status === "pending") return "当前没有待处理任务。";
|
||||
if (status === "in_progress") return "当前没有处理中的任务。";
|
||||
if (status === "completed") return "当前没有已完成任务。";
|
||||
if (status === "cancelled") return "当前没有已取消任务。";
|
||||
return "当前没有分配给你的任务。";
|
||||
}
|
||||
|
||||
export default async function TasksPage({ searchParams }: TasksPageProps) {
|
||||
const status = normalizeStatus((await searchParams).status);
|
||||
const tasks = await getTasks(status === "all" ? {} : { status });
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<PageHeader title="任务" description="查看个人和门店分配给你的事项。" />
|
||||
<FilterNav
|
||||
label="任务筛选"
|
||||
options={[
|
||||
{ label: "全部", href: "/tasks" as Route, active: status === "all" },
|
||||
{ label: "待处理", href: "/tasks?status=pending" as Route, active: status === "pending" },
|
||||
{ label: "处理中", href: "/tasks?status=in_progress" as Route, active: status === "in_progress" },
|
||||
{ label: "已完成", href: "/tasks?status=completed" as Route, active: status === "completed" }
|
||||
]}
|
||||
/>
|
||||
{tasks.length > 0 ? (
|
||||
<section className="list-stack">
|
||||
{tasks.map((task) => {
|
||||
const href = `/tasks/${task.id}` as Route;
|
||||
|
||||
return (
|
||||
<Link className="list-card" href={href} key={task.id}>
|
||||
<div>
|
||||
<h2>{task.title}</h2>
|
||||
<p>{task.description || `截止:${formatDateTime(task.dueAt)}`}</p>
|
||||
</div>
|
||||
<StatusPill tone={statusTone[task.status]}>{statusText[task.status]}</StatusPill>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
) : (
|
||||
<EmptyState title="暂无任务" description={emptyCopy(status)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { LoginForm } from "@/components/login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-hero">
|
||||
<div className="login-brand-line">
|
||||
<span className="brand-mark" aria-hidden />
|
||||
<span>Role User</span>
|
||||
</div>
|
||||
<p className="hero-kicker">Employee Console</p>
|
||||
<h1>门店工作,清晰处理</h1>
|
||||
<p>排班、任务、公告和账号安全集中在同一个移动端工作区。</p>
|
||||
<div className="login-preview" aria-hidden>
|
||||
<div className="login-preview__top">
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<div className="login-preview__body">
|
||||
<strong>08:30</strong>
|
||||
<span>早班 · 正常</span>
|
||||
</div>
|
||||
<div className="login-preview__grid">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="login-panel" aria-label="员工登录">
|
||||
<div className="login-panel__header">
|
||||
<h2>员工登录</h2>
|
||||
<p>使用手机号和临时密码进入工作台。</p>
|
||||
</div>
|
||||
<LoginForm />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { setSessionToken } from "@/lib/session";
|
||||
import { BackendError, backendRequest } from "@/lib/backend";
|
||||
import type { LoginResponse } from "@/lib/types";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await backendRequest<LoginResponse>("/auth/employee/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: body.username,
|
||||
password: body.password
|
||||
}),
|
||||
token: ""
|
||||
});
|
||||
|
||||
await setSessionToken(result.token);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
data: {
|
||||
user: result.user,
|
||||
tokenType: result.tokenType,
|
||||
expiresIn: result.expiresIn
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error instanceof BackendError ? error.status : 500;
|
||||
const message = error instanceof Error ? error.message : "登录失败";
|
||||
|
||||
return Response.json({ success: false, data: null, message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { clearSessionToken } from "@/lib/session";
|
||||
|
||||
export async function POST() {
|
||||
await clearSessionToken();
|
||||
return Response.json({ success: true, data: null });
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
await clearSessionToken();
|
||||
const next = request.nextUrl.searchParams.get("next") || "/login";
|
||||
|
||||
return NextResponse.redirect(new URL(next, request.url));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { proxyBackendJson } from "@/lib/backend";
|
||||
import type { AuthUser } from "@/lib/types";
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const body = await request.text();
|
||||
|
||||
return proxyBackendJson<AuthUser>("/auth/me/password", {
|
||||
method: "PATCH",
|
||||
body: body || undefined
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { proxyBackendJson } from "@/lib/backend";
|
||||
import type { AuthUser } from "@/lib/types";
|
||||
|
||||
export async function GET() {
|
||||
return proxyBackendJson<AuthUser>("/auth/me");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { proxyBackendJson } from "@/lib/backend";
|
||||
import type { AnnouncementDetail } from "@/lib/types";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(_request: Request, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyBackendJson<AnnouncementDetail>(`/mobile/announcements/${encodeURIComponent(id)}/read`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { proxyBackendJson } from "@/lib/backend";
|
||||
import type { AnnouncementDetail } from "@/lib/types";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyBackendJson<AnnouncementDetail>(`/mobile/announcements/${encodeURIComponent(id)}`);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { proxyBackendJson } from "@/lib/backend";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const search = new URL(request.url).search;
|
||||
return proxyBackendJson<unknown>(`/mobile/announcements${search}`);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { proxyBackendJson } from "@/lib/backend";
|
||||
import type { ShiftSummary } from "@/lib/types";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const search = new URL(request.url).search;
|
||||
return proxyBackendJson<ShiftSummary[]>(`/mobile/shifts${search}`);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { proxyBackendJson } from "@/lib/backend";
|
||||
import type { ShiftSummary } from "@/lib/types";
|
||||
|
||||
export async function GET() {
|
||||
return proxyBackendJson<ShiftSummary | null>("/mobile/shifts/today");
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { backendErrorResponse, BackendError, backendRequest } from "@/lib/backend";
|
||||
import type { ApiEnvelope, AuthUser, StoreEmployee } from "@/lib/types";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await backendRequest<AuthUser>("/auth/me");
|
||||
|
||||
if (!user.storeId) {
|
||||
return Response.json({ success: true, data: [] } satisfies ApiEnvelope<StoreEmployee[]>);
|
||||
}
|
||||
|
||||
const search = new URLSearchParams({
|
||||
storeId: String(user.storeId),
|
||||
page: "1",
|
||||
pageSize: "100"
|
||||
});
|
||||
|
||||
try {
|
||||
const employees = await backendRequest<unknown>(`/employees?${search.toString()}`);
|
||||
return Response.json({ success: true, data: employees } satisfies ApiEnvelope<unknown>);
|
||||
} catch (error) {
|
||||
if (error instanceof BackendError && [403, 404].includes(error.status)) {
|
||||
return Response.json({ success: true, data: [] } satisfies ApiEnvelope<StoreEmployee[]>);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
return backendErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { backendErrorResponse, BackendError, backendRequest } from "@/lib/backend";
|
||||
import type { ApiEnvelope, AuthUser, StoreDetail } from "@/lib/types";
|
||||
|
||||
function fallbackStore(user: AuthUser): StoreDetail | null {
|
||||
if (!user.storeId) return null;
|
||||
|
||||
return {
|
||||
id: user.storeId,
|
||||
name: user.storeName ?? "当前门店",
|
||||
address: null,
|
||||
phone: null,
|
||||
status: undefined,
|
||||
employees: []
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await backendRequest<AuthUser>("/auth/me");
|
||||
|
||||
if (!user.storeId) {
|
||||
return Response.json({ success: true, data: null } satisfies ApiEnvelope<StoreDetail | null>);
|
||||
}
|
||||
|
||||
try {
|
||||
const store = await backendRequest<StoreDetail>(`/stores/${user.storeId}`);
|
||||
return Response.json({ success: true, data: store } satisfies ApiEnvelope<StoreDetail>);
|
||||
} catch (error) {
|
||||
if (error instanceof BackendError && [403, 404].includes(error.status)) {
|
||||
return Response.json({ success: true, data: fallbackStore(user) } satisfies ApiEnvelope<StoreDetail | null>);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
return backendErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { proxyBackendJson } from "@/lib/backend";
|
||||
import type { TaskDetail } from "@/lib/types";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: Request, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const body = await request.text();
|
||||
|
||||
return proxyBackendJson<TaskDetail>(`/mobile/tasks/${encodeURIComponent(id)}/comment`, {
|
||||
method: "POST",
|
||||
body: body || undefined
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { proxyBackendJson } from "@/lib/backend";
|
||||
import type { TaskDetail } from "@/lib/types";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(_request: Request, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyBackendJson<TaskDetail>(`/mobile/tasks/${encodeURIComponent(id)}/complete`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { proxyBackendJson } from "@/lib/backend";
|
||||
import type { TaskDetail } from "@/lib/types";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyBackendJson<TaskDetail>(`/mobile/tasks/${encodeURIComponent(id)}`);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { proxyBackendJson } from "@/lib/backend";
|
||||
import type { TaskDetail } from "@/lib/types";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(_request: Request, { params }: Params) {
|
||||
const { id } = await params;
|
||||
return proxyBackendJson<TaskDetail>(`/mobile/tasks/${encodeURIComponent(id)}/start`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { proxyBackendJson } from "@/lib/backend";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const search = new URL(request.url).search;
|
||||
return proxyBackendJson<unknown>(`/mobile/tasks${search}`);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { proxyBackendJson } from "@/lib/backend";
|
||||
import type { PermissionPayload } from "@/lib/types";
|
||||
|
||||
export async function GET() {
|
||||
return proxyBackendJson<PermissionPayload>("/permissions/me");
|
||||
}
|
||||
+1109
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "员工工作台",
|
||||
description: "门店员工日常任务、排班、公告和个人中心",
|
||||
manifest: "/manifest.webmanifest",
|
||||
icons: {
|
||||
icon: "/icon.svg",
|
||||
apple: "/icon.svg"
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: "员工工作台",
|
||||
statusBarStyle: "default"
|
||||
}
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
themeColor: "#ffffff"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "role-user 员工工作台",
|
||||
short_name: "员工工作台",
|
||||
description: "门店员工任务、公告、排班和个人中心",
|
||||
start_url: "/dashboard",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
background_color: "#f4f4f6",
|
||||
theme_color: "#ffffff",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon.svg",
|
||||
sizes: "any",
|
||||
type: "image/svg+xml"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type AnnouncementReadMarkerProps = {
|
||||
id: string;
|
||||
read: boolean;
|
||||
};
|
||||
|
||||
export function AnnouncementReadMarker({ id, read }: AnnouncementReadMarkerProps) {
|
||||
const router = useRouter();
|
||||
const sentRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (read || sentRef.current) return;
|
||||
|
||||
sentRef.current = true;
|
||||
void fetch(`/api/mobile/announcements/${encodeURIComponent(id)}/read`, { method: "POST" }).then((response) => {
|
||||
if (response.ok) {
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
}, [id, read, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Bell, CalendarDays, ClipboardList, Home, UserRound } from "lucide-react";
|
||||
|
||||
const items = [
|
||||
{ href: "/dashboard", label: "工作台", icon: Home },
|
||||
{ href: "/tasks", label: "任务", icon: ClipboardList },
|
||||
{ href: "/schedule", label: "排班", icon: CalendarDays },
|
||||
{ href: "/announcements", label: "公告", icon: Bell },
|
||||
{ href: "/me", label: "我的", icon: UserRound }
|
||||
] as const;
|
||||
|
||||
export function BottomNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="bottom-nav" aria-label="主要导航">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
|
||||
return (
|
||||
<Link className={`bottom-nav__item${active ? " is-active" : ""}`} href={item.href} key={item.href}>
|
||||
<Icon aria-hidden size={20} strokeWidth={active ? 2.6 : 2} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Inbox } from "lucide-react";
|
||||
|
||||
type EmptyStateProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export function EmptyState({ title, description }: EmptyStateProps) {
|
||||
return (
|
||||
<section className="empty-state">
|
||||
<Inbox aria-hidden size={28} />
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
type FilterNavOption = {
|
||||
href: Route;
|
||||
label: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type FilterNavProps = {
|
||||
label: string;
|
||||
options: FilterNavOption[];
|
||||
};
|
||||
|
||||
export function FilterNav({ label, options }: FilterNavProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label={label}
|
||||
className="segmented"
|
||||
style={{ "--segment-count": options.length } as CSSProperties}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Link
|
||||
aria-current={option.active ? "page" : undefined}
|
||||
className={option.active ? "is-active" : undefined}
|
||||
href={option.href}
|
||||
key={option.label}
|
||||
>
|
||||
{option.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Eye, EyeOff, LockKeyhole, Smartphone } from "lucide-react";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setPending(true);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: String(formData.get("username") || "").trim(),
|
||||
password: String(formData.get("password") || "")
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
setError(payload?.message || "登录失败,请检查手机号和密码");
|
||||
setPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace("/dashboard");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
<span>手机号</span>
|
||||
<div className="input-wrap">
|
||||
<Smartphone aria-hidden size={18} />
|
||||
<input
|
||||
autoComplete="username"
|
||||
defaultValue="13211111111"
|
||||
inputMode="tel"
|
||||
name="username"
|
||||
placeholder="请输入员工手机号"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<div className="input-wrap">
|
||||
<LockKeyhole aria-hidden size={18} />
|
||||
<input
|
||||
autoComplete="current-password"
|
||||
defaultValue="pw111111"
|
||||
name="password"
|
||||
placeholder="请输入登录密码"
|
||||
required
|
||||
type={passwordVisible ? "text" : "password"}
|
||||
/>
|
||||
<button
|
||||
aria-label={passwordVisible ? "隐藏密码" : "显示密码"}
|
||||
className="icon-button"
|
||||
onClick={() => setPasswordVisible((visible) => !visible)}
|
||||
type="button"
|
||||
>
|
||||
{passwordVisible ? <EyeOff aria-hidden size={18} /> : <Eye aria-hidden size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<button className="primary-button" disabled={pending} type="submit">
|
||||
{pending ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function logout() {
|
||||
setPending(true);
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.replace("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="secondary-button danger" disabled={pending} onClick={logout} type="button">
|
||||
<LogOut aria-hidden size={18} />
|
||||
{pending ? "退出中..." : "退出登录"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
type PageHeaderProps = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function PageHeader({ eyebrow, title, description }: PageHeaderProps) {
|
||||
return (
|
||||
<header className="page-header">
|
||||
{eyebrow ? <p className="eyebrow">{eyebrow}</p> : null}
|
||||
<h1>{title}</h1>
|
||||
{description ? <p>{description}</p> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
import type { ApiEnvelope } from "@/lib/types";
|
||||
|
||||
async function readError(response: Response) {
|
||||
const payload = (await response.json().catch(() => null)) as Partial<ApiEnvelope<unknown>> | null;
|
||||
return payload?.message || "修改密码失败";
|
||||
}
|
||||
|
||||
export function PasswordChangeForm() {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setMessage("");
|
||||
setPending(true);
|
||||
|
||||
const form = event.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const oldPassword = String(formData.get("oldPassword") || "");
|
||||
const newPassword = String(formData.get("newPassword") || "");
|
||||
const confirmPassword = String(formData.get("confirmPassword") || "");
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("两次输入的新密码不一致");
|
||||
setPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/auth/me/password", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ oldPassword, newPassword })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setError(await readError(response));
|
||||
setPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset();
|
||||
setMessage("密码已修改");
|
||||
setPending(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="form-stack" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
<span>旧密码</span>
|
||||
<div className="input-wrap">
|
||||
<KeyRound aria-hidden size={18} />
|
||||
<input autoComplete="current-password" minLength={8} name="oldPassword" required type="password" />
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>新密码</span>
|
||||
<div className="input-wrap">
|
||||
<KeyRound aria-hidden size={18} />
|
||||
<input autoComplete="new-password" minLength={8} name="newPassword" required type="password" />
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>确认新密码</span>
|
||||
<div className="input-wrap">
|
||||
<KeyRound aria-hidden size={18} />
|
||||
<input autoComplete="new-password" minLength={8} name="confirmPassword" required type="password" />
|
||||
</div>
|
||||
</label>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{message ? <p className="form-success">{message}</p> : null}
|
||||
<button className="primary-button" disabled={pending} type="submit">
|
||||
<KeyRound aria-hidden size={18} />
|
||||
{pending ? "修改中..." : "修改密码"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
type StatusPillProps = {
|
||||
children: React.ReactNode;
|
||||
tone?: "default" | "success" | "warning" | "danger";
|
||||
};
|
||||
|
||||
export function StatusPill({ children, tone = "default" }: StatusPillProps) {
|
||||
return <span className={`status-pill status-pill--${tone}`}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CheckCircle2, MessageSquarePlus, PlayCircle } from "lucide-react";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
import type { ApiEnvelope, TaskDetail, TaskStatus } from "@/lib/types";
|
||||
|
||||
type TaskActionPanelProps = {
|
||||
taskId: string;
|
||||
initialStatus: TaskStatus;
|
||||
};
|
||||
|
||||
type TaskAction = "start" | "complete" | "comment";
|
||||
|
||||
function normalizeStatus(value: unknown): TaskStatus | null {
|
||||
switch (value) {
|
||||
case "PENDING":
|
||||
case "pending":
|
||||
return "pending";
|
||||
case "IN_PROGRESS":
|
||||
case "in_progress":
|
||||
return "in_progress";
|
||||
case "COMPLETED":
|
||||
case "completed":
|
||||
return "completed";
|
||||
case "CANCELLED":
|
||||
case "cancelled":
|
||||
return "cancelled";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readMessage(response: Response) {
|
||||
const payload = (await response.json().catch(() => null)) as Partial<ApiEnvelope<unknown>> | null;
|
||||
return payload?.message || "操作失败";
|
||||
}
|
||||
|
||||
export function TaskActionPanel({ taskId, initialStatus }: TaskActionPanelProps) {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
const [pendingAction, setPendingAction] = useState<TaskAction | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function runAction(action: Exclude<TaskAction, "comment">) {
|
||||
setError("");
|
||||
setMessage("");
|
||||
setPendingAction(action);
|
||||
|
||||
const response = await fetch(`/api/mobile/tasks/${encodeURIComponent(taskId)}/${action}`, { method: "POST" });
|
||||
|
||||
if (!response.ok) {
|
||||
setError(await readMessage(response));
|
||||
setPendingAction(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as ApiEnvelope<TaskDetail> | null;
|
||||
const nextStatus = normalizeStatus(payload?.data?.status);
|
||||
if (nextStatus) setStatus(nextStatus);
|
||||
|
||||
setMessage(action === "start" ? "任务已开始处理" : "任务已完成");
|
||||
setPendingAction(null);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function submitComment(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setMessage("");
|
||||
setPendingAction("comment");
|
||||
|
||||
const form = event.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const comment = String(formData.get("comment") || "").trim();
|
||||
|
||||
if (!comment) {
|
||||
setError("请填写备注内容");
|
||||
setPendingAction(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/mobile/tasks/${encodeURIComponent(taskId)}/comment`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ comment })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setError(await readMessage(response));
|
||||
setPendingAction(null);
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset();
|
||||
setMessage("备注已追加");
|
||||
setPendingAction(null);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const canStart = status === "pending";
|
||||
const canComplete = status === "pending" || status === "in_progress";
|
||||
|
||||
return (
|
||||
<section className="section-block">
|
||||
<div className="section-title">
|
||||
<h2>处理任务</h2>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={!canStart || pendingAction !== null}
|
||||
onClick={() => void runAction("start")}
|
||||
type="button"
|
||||
>
|
||||
<PlayCircle aria-hidden size={18} />
|
||||
{pendingAction === "start" ? "开始中..." : "开始处理"}
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={!canComplete || pendingAction !== null}
|
||||
onClick={() => void runAction("complete")}
|
||||
type="button"
|
||||
>
|
||||
<CheckCircle2 aria-hidden size={18} />
|
||||
{pendingAction === "complete" ? "完成中..." : "完成任务"}
|
||||
</button>
|
||||
</div>
|
||||
<form className="form-stack" onSubmit={submitComment}>
|
||||
<label>
|
||||
<span>处理备注</span>
|
||||
<textarea className="text-area" maxLength={1000} name="comment" placeholder="填写交接、处理进展或完成说明" />
|
||||
</label>
|
||||
<button className="secondary-button" disabled={pendingAction !== null} type="submit">
|
||||
<MessageSquarePlus aria-hidden size={18} />
|
||||
{pendingAction === "comment" ? "提交中..." : "追加备注"}
|
||||
</button>
|
||||
</form>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{message ? <p className="form-success">{message}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import "server-only";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { clearSessionToken, getSessionToken } from "@/lib/session";
|
||||
import type { ApiEnvelope } from "@/lib/types";
|
||||
|
||||
export class BackendError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function getBackendBaseUrl() {
|
||||
return process.env.ACCESS_MANAGE_API_BASE_URL || "http://localhost:3500/api";
|
||||
}
|
||||
|
||||
function toUrl(path: string) {
|
||||
return new URL(path.replace(/^\//, ""), `${getBackendBaseUrl().replace(/\/$/, "")}/`);
|
||||
}
|
||||
|
||||
async function parseBackendResponse<T>(response: Response): Promise<T> {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const payload = contentType.includes("application/json") ? await response.json() : null;
|
||||
|
||||
if (!response.ok || payload?.success === false) {
|
||||
throw new BackendError(payload?.message || response.statusText || "请求失败", response.status);
|
||||
}
|
||||
|
||||
if (payload && typeof payload === "object" && "data" in payload) {
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export async function backendRequest<T>(
|
||||
path: string,
|
||||
init: RequestInit & { token?: string; allowUnauthorized?: boolean } = {}
|
||||
) {
|
||||
const token = init.token ?? (await getSessionToken());
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
headers.set("Accept", "application/json");
|
||||
if (init.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(toUrl(path), {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
return parseBackendResponse<T>(response);
|
||||
}
|
||||
|
||||
export async function requireBackendData<T>(path: string) {
|
||||
try {
|
||||
return await backendRequest<T>(path);
|
||||
} catch (error) {
|
||||
if (error instanceof BackendError && error.status === 401) {
|
||||
redirect("/api/auth/logout?next=/login");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function proxyBackendJson<T>(path: string, init: RequestInit = {}) {
|
||||
try {
|
||||
const data = await backendRequest<T>(path, init);
|
||||
return Response.json({ success: true, data } satisfies ApiEnvelope<T>);
|
||||
} catch (error) {
|
||||
return backendErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function backendErrorResponse(error: unknown, fallbackMessage = "请求失败") {
|
||||
const status = error instanceof BackendError ? error.status : 500;
|
||||
const message = error instanceof Error ? error.message : fallbackMessage;
|
||||
|
||||
if (status === 401) {
|
||||
await clearSessionToken();
|
||||
}
|
||||
|
||||
return Response.json({ success: false, data: null, message }, { status });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function formatDateTime(value?: string | null) {
|
||||
if (!value) return "暂未设置";
|
||||
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function roleNames(roles: { name: string }[]) {
|
||||
return roles.map((role) => role.name).join("、") || "未分配角色";
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import "server-only";
|
||||
|
||||
import { BackendError, backendRequest, requireBackendData } from "@/lib/backend";
|
||||
import type {
|
||||
AnnouncementDetail,
|
||||
AnnouncementSummary,
|
||||
AuthUser,
|
||||
MobileBootstrap,
|
||||
PermissionPayload,
|
||||
ShiftSummary,
|
||||
StoreDetail,
|
||||
StoreEmployee,
|
||||
TaskAssignee,
|
||||
TaskDetail,
|
||||
TaskEvent,
|
||||
TaskSummary
|
||||
} from "@/lib/types";
|
||||
|
||||
const EMPTY_TASKS: TaskSummary[] = [];
|
||||
const EMPTY_ANNOUNCEMENTS: AnnouncementSummary[] = [];
|
||||
const EMPTY_SHIFTS: ShiftSummary[] = [];
|
||||
const OPTIONAL_MOBILE_FALLBACK_STATUSES = [404, 500, 501, 502, 503];
|
||||
const OPTIONAL_STORE_FALLBACK_STATUSES = [403, 404, 500, 501, 502, 503];
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
export type TaskListFilter = {
|
||||
status?: TaskSummary["status"];
|
||||
};
|
||||
|
||||
export type ShiftListFilter = {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringOrUndefined(value: unknown) {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function numberOrUndefined(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function numericIdOrUndefined(value: unknown) {
|
||||
if (typeof value === "number" && Number.isInteger(value)) return value;
|
||||
if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toList<T>(value: unknown, normalize: (item: unknown) => T | null): T[] {
|
||||
const source = Array.isArray(value) ? value : isRecord(value) && Array.isArray(value.items) ? value.items : [];
|
||||
return source.map(normalize).filter((item): item is T => item !== null);
|
||||
}
|
||||
|
||||
function normalizeTaskStatus(value: unknown): TaskSummary["status"] {
|
||||
switch (value) {
|
||||
case "PENDING":
|
||||
case "pending":
|
||||
return "pending";
|
||||
case "IN_PROGRESS":
|
||||
case "in_progress":
|
||||
return "in_progress";
|
||||
case "COMPLETED":
|
||||
case "completed":
|
||||
return "completed";
|
||||
case "CANCELLED":
|
||||
case "cancelled":
|
||||
return "cancelled";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTaskPriority(value: unknown): TaskSummary["priority"] {
|
||||
switch (value) {
|
||||
case "LOW":
|
||||
case "low":
|
||||
return "low";
|
||||
case "HIGH":
|
||||
case "high":
|
||||
return "high";
|
||||
case "URGENT":
|
||||
case "urgent":
|
||||
return "urgent";
|
||||
default:
|
||||
return "normal";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAnnouncementLevel(value: unknown): AnnouncementSummary["level"] {
|
||||
switch (value) {
|
||||
case "IMPORTANT":
|
||||
case "important":
|
||||
return "important";
|
||||
case "URGENT":
|
||||
case "urgent":
|
||||
return "urgent";
|
||||
default:
|
||||
return "normal";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeShiftStatus(value: unknown): ShiftSummary["status"] {
|
||||
switch (value) {
|
||||
case "CANCELLED":
|
||||
case "cancelled":
|
||||
return "cancelled";
|
||||
case "COMPLETED":
|
||||
case "completed":
|
||||
return "completed";
|
||||
default:
|
||||
return "scheduled";
|
||||
}
|
||||
}
|
||||
|
||||
function summaryFromContent(value: unknown) {
|
||||
const content = stringOrUndefined(value);
|
||||
if (!content) return undefined;
|
||||
|
||||
const text = content.replace(/\s+/g, " ").trim();
|
||||
return text.length > 48 ? `${text.slice(0, 48)}...` : text;
|
||||
}
|
||||
|
||||
function normalizeTask(value: unknown): TaskSummary | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const title = stringOrUndefined(value.title) ?? "未命名任务";
|
||||
const assignees = toList(value.assignees, normalizeTaskAssignee);
|
||||
|
||||
return {
|
||||
id: String(value.id ?? title),
|
||||
title,
|
||||
description: stringOrUndefined(value.description),
|
||||
status: normalizeTaskStatus(value.status),
|
||||
priority: normalizeTaskPriority(value.priority),
|
||||
dueAt: stringOrUndefined(value.dueAt) ?? null,
|
||||
assigneeName: stringOrUndefined(value.assigneeName) ?? assignees[0]?.name,
|
||||
storeName: stringOrUndefined(value.storeName)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskAssignee(value: unknown): TaskAssignee | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const id = numericIdOrUndefined(value.id);
|
||||
const name = stringOrUndefined(value.name) ?? stringOrUndefined(value.displayName);
|
||||
if (id === undefined || !name) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
phone: stringOrUndefined(value.phone) ?? stringOrUndefined(value.username)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskEvent(value: unknown): TaskEvent | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const id = numericIdOrUndefined(value.id);
|
||||
const eventType = stringOrUndefined(value.eventType);
|
||||
if (id === undefined || !eventType) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
taskId: numericIdOrUndefined(value.taskId),
|
||||
eventType,
|
||||
fromStatus: stringOrUndefined(value.fromStatus) ?? null,
|
||||
toStatus: stringOrUndefined(value.toStatus) ?? null,
|
||||
comment: stringOrUndefined(value.comment) ?? null,
|
||||
createdAt: stringOrUndefined(value.createdAt) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskDetail(value: unknown): TaskDetail {
|
||||
if (!isRecord(value)) {
|
||||
throw new BackendError("任务详情数据格式不正确", 502);
|
||||
}
|
||||
|
||||
const summary = normalizeTask(value);
|
||||
if (!summary) {
|
||||
throw new BackendError("任务详情数据格式不正确", 502);
|
||||
}
|
||||
|
||||
return {
|
||||
...summary,
|
||||
assignees: toList(value.assignees, normalizeTaskAssignee),
|
||||
events: toList(value.events, normalizeTaskEvent),
|
||||
createdAt: stringOrUndefined(value.createdAt) ?? null,
|
||||
updatedAt: stringOrUndefined(value.updatedAt) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnnouncement(value: unknown): AnnouncementSummary | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const title = stringOrUndefined(value.title) ?? "未命名公告";
|
||||
|
||||
return {
|
||||
id: String(value.id ?? title),
|
||||
title,
|
||||
summary: stringOrUndefined(value.summary) ?? summaryFromContent(value.content),
|
||||
level: normalizeAnnouncementLevel(value.level),
|
||||
publishedAt: stringOrUndefined(value.publishedAt) ?? null,
|
||||
read: typeof value.read === "boolean" ? value.read : Boolean(value.readAt)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnnouncementDetail(value: unknown): AnnouncementDetail {
|
||||
if (!isRecord(value)) {
|
||||
throw new BackendError("公告详情数据格式不正确", 502);
|
||||
}
|
||||
|
||||
const summary = normalizeAnnouncement(value);
|
||||
if (!summary) {
|
||||
throw new BackendError("公告详情数据格式不正确", 502);
|
||||
}
|
||||
|
||||
return {
|
||||
...summary,
|
||||
content: stringOrUndefined(value.content) ?? summary.summary ?? "",
|
||||
readAt: stringOrUndefined(value.readAt) ?? null,
|
||||
createdAt: stringOrUndefined(value.createdAt) ?? null,
|
||||
updatedAt: stringOrUndefined(value.updatedAt) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeShift(value: unknown): ShiftSummary | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const startAt = stringOrUndefined(value.startAt) ?? "";
|
||||
const endAt = stringOrUndefined(value.endAt) ?? "";
|
||||
|
||||
return {
|
||||
id: String(value.id ?? startAt),
|
||||
date: stringOrUndefined(value.date) ?? startAt.slice(0, 10),
|
||||
position: stringOrUndefined(value.position) ?? stringOrUndefined(value.roleName) ?? "班次",
|
||||
startAt,
|
||||
endAt,
|
||||
status: normalizeShiftStatus(value.status)
|
||||
};
|
||||
}
|
||||
|
||||
function firstShift(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return normalizeShift(value[0]);
|
||||
}
|
||||
|
||||
return normalizeShift(value);
|
||||
}
|
||||
|
||||
function shiftList(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalizeShift).filter((item): item is ShiftSummary => item !== null);
|
||||
}
|
||||
|
||||
const shift = normalizeShift(value);
|
||||
return shift ? [shift] : [];
|
||||
}
|
||||
|
||||
function permissionsFromBootstrap(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
const codes = Array.isArray(value.codes) ? value.codes : value.permissions;
|
||||
return Array.isArray(codes) ? codes.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeRole(value: unknown) {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const id = numericIdOrUndefined(value.id);
|
||||
const code = stringOrUndefined(value.code);
|
||||
const name = stringOrUndefined(value.name);
|
||||
|
||||
if (id === undefined || !code || !name) return null;
|
||||
|
||||
return { id, code, name };
|
||||
}
|
||||
|
||||
function normalizeStoreEmployee(value: unknown): StoreEmployee | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const id = numericIdOrUndefined(value.id);
|
||||
const name = stringOrUndefined(value.name) ?? stringOrUndefined(value.displayName);
|
||||
if (id === undefined || !name) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
phone: stringOrUndefined(value.phone) ?? stringOrUndefined(value.username),
|
||||
status: stringOrUndefined(value.status),
|
||||
roles: toList(value.roles, normalizeRole)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStoreDetail(value: unknown, fallbackUser?: AuthUser): StoreDetail | null {
|
||||
if (!isRecord(value)) {
|
||||
if (!fallbackUser?.storeId) return null;
|
||||
|
||||
return {
|
||||
id: fallbackUser.storeId,
|
||||
name: fallbackUser.storeName ?? "当前门店",
|
||||
address: null,
|
||||
phone: null,
|
||||
status: undefined,
|
||||
employees: []
|
||||
};
|
||||
}
|
||||
|
||||
const id = numericIdOrUndefined(value.id) ?? fallbackUser?.storeId;
|
||||
const name = stringOrUndefined(value.name) ?? fallbackUser?.storeName;
|
||||
if (id === undefined || !name) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
address: stringOrUndefined(value.address) ?? null,
|
||||
phone: stringOrUndefined(value.phone) ?? null,
|
||||
status: stringOrUndefined(value.status),
|
||||
employees: toList(value.employees, normalizeStoreEmployee)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBootstrap(data: unknown): MobileBootstrap {
|
||||
if (!isRecord(data) || !isRecord(data.user)) {
|
||||
throw new BackendError("员工端首屏数据格式不正确", 502);
|
||||
}
|
||||
|
||||
const counters = isRecord(data.counters) ? data.counters : {};
|
||||
const tasks = toList(data.tasks, normalizeTask).slice(0, 5);
|
||||
const latestAnnouncements = toList(data.latestAnnouncements, normalizeAnnouncement).slice(0, 3);
|
||||
const todayShifts = shiftList(data.todayShifts ?? data.todayShift);
|
||||
const todayShift = firstShift(data.todayShift) ?? todayShifts[0] ?? null;
|
||||
const pendingTasks = tasks.filter((task) => task.status === "pending" || task.status === "in_progress");
|
||||
const now = Date.now();
|
||||
|
||||
return {
|
||||
user: data.user as AuthUser,
|
||||
permissions: permissionsFromBootstrap(data.permissions),
|
||||
unreadAnnouncementCount:
|
||||
numberOrUndefined(data.unreadAnnouncementCount ?? counters.unreadAnnouncementCount) ??
|
||||
latestAnnouncements.filter((item) => !item.read).length,
|
||||
pendingTaskCount: numberOrUndefined(data.pendingTaskCount ?? counters.pendingTaskCount) ?? pendingTasks.length,
|
||||
overdueTaskCount:
|
||||
numberOrUndefined(data.overdueTaskCount ?? counters.overdueTaskCount) ??
|
||||
pendingTasks.filter((task) => task.dueAt && new Date(task.dueAt).getTime() < now).length,
|
||||
todayShift,
|
||||
todayShifts,
|
||||
latestAnnouncements,
|
||||
tasks
|
||||
};
|
||||
}
|
||||
|
||||
async function optionalBackendData<T>(path: string, fallback: T, fallbackStatuses = [404]) {
|
||||
try {
|
||||
return await backendRequest<T>(path);
|
||||
} catch (error) {
|
||||
if (error instanceof BackendError && fallbackStatuses.includes(error.status)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function withSearch(path: string, entries: Record<string, string | undefined>) {
|
||||
const search = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (value) {
|
||||
search.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const query = search.toString();
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
|
||||
function taskStatusToBackend(status?: TaskSummary["status"]) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "PENDING";
|
||||
case "in_progress":
|
||||
return "IN_PROGRESS";
|
||||
case "completed":
|
||||
return "COMPLETED";
|
||||
case "cancelled":
|
||||
return "CANCELLED";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBootstrapData(): Promise<MobileBootstrap> {
|
||||
try {
|
||||
return normalizeBootstrap(await requireBackendData<unknown>("/mobile/bootstrap"));
|
||||
} catch (error) {
|
||||
if (!(error instanceof BackendError && OPTIONAL_MOBILE_FALLBACK_STATUSES.includes(error.status))) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const [user, permissionPayload, tasks, announcements, todayShift] = await Promise.all([
|
||||
requireBackendData<AuthUser>("/auth/me"),
|
||||
requireBackendData<PermissionPayload>("/permissions/me"),
|
||||
optionalBackendData<unknown>("/mobile/tasks", EMPTY_TASKS, OPTIONAL_MOBILE_FALLBACK_STATUSES),
|
||||
optionalBackendData<unknown>("/mobile/announcements", EMPTY_ANNOUNCEMENTS, OPTIONAL_MOBILE_FALLBACK_STATUSES),
|
||||
optionalBackendData<unknown>("/mobile/shifts/today", null, OPTIONAL_MOBILE_FALLBACK_STATUSES)
|
||||
]);
|
||||
|
||||
const now = Date.now();
|
||||
const normalizedTasks = toList(tasks, normalizeTask);
|
||||
const normalizedAnnouncements = toList(announcements, normalizeAnnouncement);
|
||||
const todayShifts = shiftList(todayShift);
|
||||
const pendingTasks = normalizedTasks.filter((task) => task.status === "pending" || task.status === "in_progress");
|
||||
|
||||
return {
|
||||
user,
|
||||
permissions: permissionPayload.permissions,
|
||||
unreadAnnouncementCount: normalizedAnnouncements.filter((item) => !item.read).length,
|
||||
pendingTaskCount: pendingTasks.length,
|
||||
overdueTaskCount: pendingTasks.filter((task) => task.dueAt && new Date(task.dueAt).getTime() < now).length,
|
||||
todayShift: todayShifts[0] ?? null,
|
||||
todayShifts,
|
||||
latestAnnouncements: normalizedAnnouncements.slice(0, 3),
|
||||
tasks: normalizedTasks.slice(0, 5)
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCurrentUser() {
|
||||
return requireBackendData<AuthUser>("/auth/me");
|
||||
}
|
||||
|
||||
export async function getPermissionPayload() {
|
||||
return requireBackendData<PermissionPayload>("/permissions/me");
|
||||
}
|
||||
|
||||
export async function getTasks(filter: TaskListFilter = {}) {
|
||||
const path = withSearch("/mobile/tasks", {
|
||||
status: taskStatusToBackend(filter.status)
|
||||
});
|
||||
|
||||
return toList(
|
||||
await optionalBackendData<unknown>(path, EMPTY_TASKS, OPTIONAL_MOBILE_FALLBACK_STATUSES),
|
||||
normalizeTask
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTaskDetail(id: string) {
|
||||
return normalizeTaskDetail(await requireBackendData<unknown>(`/mobile/tasks/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
export async function getAnnouncements() {
|
||||
return toList(
|
||||
await optionalBackendData<unknown>("/mobile/announcements", EMPTY_ANNOUNCEMENTS, OPTIONAL_MOBILE_FALLBACK_STATUSES),
|
||||
normalizeAnnouncement
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAnnouncementDetail(id: string) {
|
||||
return normalizeAnnouncementDetail(
|
||||
await requireBackendData<unknown>(`/mobile/announcements/${encodeURIComponent(id)}`)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getShifts(filter: ShiftListFilter = {}) {
|
||||
const path = withSearch("/mobile/shifts", {
|
||||
startDate: filter.startDate,
|
||||
endDate: filter.endDate
|
||||
});
|
||||
|
||||
return toList(
|
||||
await optionalBackendData<unknown>(path, EMPTY_SHIFTS, OPTIONAL_MOBILE_FALLBACK_STATUSES),
|
||||
normalizeShift
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCurrentStore(user?: AuthUser) {
|
||||
const currentUser = user ?? (await getCurrentUser());
|
||||
if (!currentUser.storeId) return null;
|
||||
|
||||
const fallback = normalizeStoreDetail(null, currentUser);
|
||||
return normalizeStoreDetail(
|
||||
await optionalBackendData<unknown>(`/stores/${currentUser.storeId}`, fallback, OPTIONAL_STORE_FALLBACK_STATUSES),
|
||||
currentUser
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCurrentStoreEmployees(user?: AuthUser) {
|
||||
const currentUser = user ?? (await getCurrentUser());
|
||||
if (!currentUser.storeId) return [];
|
||||
|
||||
const search = new URLSearchParams({
|
||||
storeId: String(currentUser.storeId),
|
||||
page: "1",
|
||||
pageSize: "100"
|
||||
});
|
||||
|
||||
return toList(
|
||||
await optionalBackendData<unknown>(`/employees?${search.toString()}`, [], OPTIONAL_STORE_FALLBACK_STATUSES),
|
||||
normalizeStoreEmployee
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import "server-only";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const DEFAULT_COOKIE_NAME = "role_user_session";
|
||||
|
||||
export function getSessionCookieName() {
|
||||
return process.env.ROLE_USER_SESSION_COOKIE || DEFAULT_COOKIE_NAME;
|
||||
}
|
||||
|
||||
export async function getSessionToken() {
|
||||
return (await cookies()).get(getSessionCookieName())?.value;
|
||||
}
|
||||
|
||||
export async function setSessionToken(token: string) {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
cookieStore.set(getSessionCookieName(), token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 8
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearSessionToken() {
|
||||
(await cookies()).delete(getSessionCookieName());
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
export type AccountType = "SUPER_ADMIN" | "EMPLOYEE";
|
||||
|
||||
export type Role = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type AuthUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
accountType: AccountType;
|
||||
storeId?: number;
|
||||
storeName?: string;
|
||||
roles: Role[];
|
||||
permissions: string[];
|
||||
canManage: boolean;
|
||||
lastLoginAt?: string | null;
|
||||
mustChangePassword?: boolean;
|
||||
};
|
||||
|
||||
export type PermissionMenu = {
|
||||
key: string;
|
||||
title: string;
|
||||
permission: string;
|
||||
actions: string[];
|
||||
};
|
||||
|
||||
export type PermissionPayload = {
|
||||
permissions: string[];
|
||||
menus: PermissionMenu[];
|
||||
};
|
||||
|
||||
export type ApiEnvelope<T> = {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
token: string;
|
||||
tokenType: "Bearer";
|
||||
expiresIn: string;
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
export type TaskStatus = "pending" | "in_progress" | "completed" | "cancelled";
|
||||
|
||||
export type TaskSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: TaskStatus;
|
||||
priority: "low" | "normal" | "high" | "urgent";
|
||||
dueAt?: string | null;
|
||||
assigneeName?: string;
|
||||
storeName?: string;
|
||||
};
|
||||
|
||||
export type TaskAssignee = {
|
||||
id: number;
|
||||
name: string;
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
export type TaskEvent = {
|
||||
id: number;
|
||||
taskId?: number;
|
||||
eventType: "CREATED" | "UPDATED" | "STARTED" | "COMPLETED" | "CANCELLED" | "COMMENTED" | string;
|
||||
fromStatus?: string | null;
|
||||
toStatus?: string | null;
|
||||
comment?: string | null;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
export type TaskDetail = TaskSummary & {
|
||||
assignees: TaskAssignee[];
|
||||
events: TaskEvent[];
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type AnnouncementSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
level: "normal" | "important" | "urgent";
|
||||
publishedAt?: string | null;
|
||||
read: boolean;
|
||||
};
|
||||
|
||||
export type AnnouncementDetail = AnnouncementSummary & {
|
||||
content: string;
|
||||
readAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type ShiftSummary = {
|
||||
id: string;
|
||||
date: string;
|
||||
position: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
status: "scheduled" | "cancelled" | "completed";
|
||||
};
|
||||
|
||||
export type StoreEmployee = {
|
||||
id: number;
|
||||
name: string;
|
||||
phone?: string;
|
||||
status?: string;
|
||||
roles: Role[];
|
||||
};
|
||||
|
||||
export type StoreDetail = {
|
||||
id: number;
|
||||
name: string;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
status?: string;
|
||||
employees: StoreEmployee[];
|
||||
};
|
||||
|
||||
export type MobileBootstrap = {
|
||||
user: AuthUser;
|
||||
permissions: string[];
|
||||
unreadAnnouncementCount: number;
|
||||
pendingTaskCount: number;
|
||||
overdueTaskCount: number;
|
||||
todayShift: ShiftSummary | null;
|
||||
todayShifts: ShiftSummary[];
|
||||
latestAnnouncements: AnnouncementSummary[];
|
||||
tasks: TaskSummary[];
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const protectedRoutes = ["/dashboard", "/tasks", "/schedule", "/announcements", "/store", "/me"];
|
||||
const publicRoutes = ["/", "/login"];
|
||||
const cookieName = process.env.ROLE_USER_SESSION_COOKIE || "role_user_session";
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname;
|
||||
const hasSession = Boolean(request.cookies.get(cookieName)?.value);
|
||||
const isProtected = protectedRoutes.some((route) => path === route || path.startsWith(`${route}/`));
|
||||
const isPublic = publicRoutes.includes(path);
|
||||
|
||||
if (isProtected && !hasSession) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
|
||||
if (isPublic && hasSession) {
|
||||
return NextResponse.redirect(new URL("/dashboard", request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next/static|_next/image|manifest.webmanifest|sw.js|.*\\..*).*)"]
|
||||
};
|
||||
Reference in New Issue
Block a user