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");
|
||||
}
|
||||
Reference in New Issue
Block a user