Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ CI(`ci.yml`)对每个指向 main 的 PR 跑以下全部检查。
- 安装:`npm ci`
- lint:`npm run lint`
- 类型检查:`npm run typecheck`(`tsc --noEmit`;lint 和测试的 TS 转译都不做类型检查,类型错误只有这一步拦得住)
- 测试:`npm test`,先 `vinext build` 生产构建,再跑 `tests/**/*.test.mjs`。
- 测试:`npm test`,先 `vinext build` 生产构建,再跑 `tests/**/*.test.mjs`。CI 跑在 UTC,断言渲染出的日期或时分时,测试数据用 `new Date(年, 月, 日, 时, 分)` 按本地时间构造,不要写死带时区的字符串;本地可用 `TZ=UTC npm test` 复查。
- 本地开发:`npm run dev`,后端地址见 `frontend/.env.example`(默认 `http://127.0.0.1:18080`)。

**提交信息检查**(在仓库根目录下)
Expand Down Expand Up @@ -64,6 +64,7 @@ CI(`ci.yml`)对每个指向 main 的 PR 跑以下全部检查。
- 角色导航以 `app/lib/roles.ts` 的 `navItemsForRole` 为准;`tests/rendered-html.test.mjs` 里有一份对照,改导航时两处一起改。
- 筛选项(下拉选项、标签列表)不要从当前页、已筛选的列表数据里推导:换页或加筛选后选项会消失。由后端单独接口提供,例如设备类别用 `GET /api/equipment/categories`。测试要覆盖「筛选后」和「数据在第一页之外」两种情况。
- 设备名录这类只用来装饰行的数据,不要和页面自己的数据放进同一个 `Promise.all`:名录失败会连带已经成功的行一起丢掉。用 `page.tsx` 的 `settle` 单独等待,失败时行回退到编号并单独提示;测试要覆盖「主数据成功、名录失败」。
- 给 `.solid-button`、`.ghost-button` 这类通用类加页面专属尺寸时,选择器要带上父级(如 `.login-card .login-submit`):单个类名写在通用样式前面会按源码顺序被覆盖。jsdom 的 `getComputedStyle` 不算优先级,测不出这种覆盖,要断言样式时用 `tests/css-cascade.mjs` 的 `cascadedValue`。
- 菜单、下拉层等浮层不要渲染在 `overflow` 滚动容器里(表格外层的 `.table-scroll` 会裁掉纵向溢出),用 portal 挂到 `body` 再按触发按钮定位,参照 `EquipmentView` 的 `MoreMenu`。测试至少覆盖一行、两行和末行三种情况。

## 编辑方式
Expand Down
86 changes: 55 additions & 31 deletions frontend/app/components/LoginView.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import type { FormEvent } from "react";
import type { ToastState } from "../lib/types";
import { Icon, type IconName } from "./icons";
import { roleStyle } from "./roleTag";
import { Brand, Toast } from "./ui";

type LoginState = { username: string; password: string };

/** Seeded demo accounts (DemoUserInitializer); a card fills the form so the visitor can sign in at once. */
export const DEMO_ACCOUNTS = [
{ role: "STUDENT", label: "学生", note: "查看设备,提交预约与报修", username: "student", password: "student123" },
{ role: "TEACHER", label: "教师", note: "审批预约,管理实验设备", username: "teacher", password: "teacher123" },
{ role: "TECHNICIAN", label: "维修员", note: "接单处理,跟踪工单进度", username: "technician", password: "tech123" },
{ role: "ADMIN", label: "管理员", note: "全局管理、支付对账与审计", username: "admin", password: "admin123" },
] as const;

const STORY_POINTS: { icon: IconName; text: string }[] = [
{ icon: "box", text: "设备状态一眼看清" },
{ icon: "calendar", text: "预约冲突实时拦截" },
{ icon: "wrench", text: "故障进度全程追踪" },
];

export function LoginView({ login, busy, toast, onLoginChange, onSubmit, onDemo }: {
login: LoginState;
busy: boolean;
Expand All @@ -13,37 +29,45 @@ export function LoginView({ login, busy, toast, onLoginChange, onSubmit, onDemo
onDemo: (username: string, password: string) => void;
}) {
return <main className="login-shell">
<section className="login-story">
<Brand light />
<div className="story-copy">
<span className="eyebrow">LAB OPERATIONS GRID / 2026</span>
<h1>设备预约与维护,<br />一张工作台搞定。</h1>
<p>用清晰的排期、设备状态和维修进度,让实验室每天都按计划运转。</p>
</div>
<div className="story-metrics">
<div><strong>01 / 排期</strong><span>预约冲突实时拦截</span></div>
<div><strong>02 / 设备</strong><span>运行状态一眼识别</span></div>
<div><strong>03 / 维修</strong><span>故障进度全程追踪</span></div>
</div>
</section>
<section className="login-panel">
<Brand mobile />
<form className="login-card" onSubmit={onSubmit}>
<span className="eyebrow">SECURE ACCESS / 安全访问</span>
<h2>进入设备运行台</h2>
<p>使用实验室账号进入对应角色空间</p>
<label>账号<input value={login.username} onChange={(e) => onLoginChange({ ...login, username: e.target.value })} autoComplete="username" required /></label>
<label>密码<input type="password" value={login.password} onChange={(e) => onLoginChange({ ...login, password: e.target.value })} autoComplete="current-password" required /></label>
<button className="primary wide" disabled={busy}>{busy ? "正在验证…" : "进入 LabFlow"}<span>→</span></button>
<div className="demo-divider"><span>快速体验角色</span></div>
<div className="demo-grid">
<button type="button" onClick={() => onDemo("student", "student123")}><b>学生</b><small>提交预约与报修</small></button>
<button type="button" onClick={() => onDemo("teacher", "teacher123")}><b>教师</b><small>审批与设备管理</small></button>
<button type="button" onClick={() => onDemo("technician", "tech123")}><b>维修员</b><small>处理故障工单</small></button>
<button type="button" onClick={() => onDemo("admin", "admin123")}><b>管理员</b><small>全局管理与审计</small></button>
</div>
</form>
</section>
<header className="login-bar"><Brand light /></header>
<div className="login-stage">
<section className="login-story">
<i className="hero-rule" aria-hidden="true" />
<h1>让实验室运营更高效</h1>
<p>连接设备、预约、维修与支付对账,让高校实验室每天都按计划运转。</p>
<ul className="story-points">
{STORY_POINTS.map((point) => <li key={point.text}><span><Icon name={point.icon} size={18} /></span>{point.text}</li>)}
</ul>
</section>
<section className="login-panel">
<form className="login-card" onSubmit={onSubmit}>
<h2>登录 LabFlow</h2>
<label className="icon-field">
<Icon name="user" size={19} />
<input aria-label="账号" placeholder="账号" value={login.username} onChange={(e) => onLoginChange({ ...login, username: e.target.value })} autoComplete="username" required />
</label>
<label className="icon-field">
<Icon name="lock" size={19} />
<input aria-label="密码" placeholder="密码" type="password" value={login.password} onChange={(e) => onLoginChange({ ...login, password: e.target.value })} autoComplete="current-password" required />
</label>
<button className="solid-button login-submit" disabled={busy}>{busy ? "正在验证…" : "登录"}</button>
<div className="demo-divider"><span>快速体验</span></div>
<div className="demo-grid">
{DEMO_ACCOUNTS.map((account) => {
const style = roleStyle(account.role);
const selected = login.username === account.username;
return <button type="button" key={account.role} data-demo={account.role} aria-pressed={selected} className={selected ? "selected" : ""}
onClick={() => onDemo(account.username, account.password)}>
<span className={`demo-icon tone-${style.tone}`}><Icon name={style.icon} size={20} /></span>
<span className="demo-copy"><b>{account.label}</b><small>{account.note}</small></span>
<Icon name="chevron" size={16} className="demo-arrow" />
</button>;
})}
</div>
<p className="demo-hint">点选角色会填好演示账号,再点「登录」即可进入</p>
</form>
</section>
</div>
<Toast toast={toast} />
</main>;
}
4 changes: 4 additions & 0 deletions frontend/app/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ const paths = {
gem: <><path d="M6.5 4h11L21 9l-9 11.5L3 9z" /><path d="M3 9h18M9.5 4 8 9l4 11.5L16 9l-1.5-5" /></>,
dna: <><path d="M7 3c0 6 10 6 10 12s-10 6-10 6" /><path d="M17 3c0 6-10 6-10 12M17 21v0" /><path d="M8.5 7h7M8.5 17h7M10 12h4" /></>,
waves: <path d="M3 8c2.2-2 3.8-2 6 0s3.8 2 6 0 3.8-2 6 0M3 13c2.2-2 3.8-2 6 0s3.8 2 6 0 3.8-2 6 0M3 18c2.2-2 3.8-2 6 0s3.8 2 6 0 3.8-2 6 0" />,
lock: <><rect x="5" y="10.5" width="14" height="10" rx="2.5" /><path d="M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3M12 14.5v2.5" /></>,
cap: <><path d="M2.5 9.5 12 5l9.5 4.5L12 14z" /><path d="M6.5 11.5v4.2c1.4 1.4 3.3 2.3 5.5 2.3s4.1-.9 5.5-2.3v-4.2M21.5 9.5v5" /></>,
gear: <><circle cx="12" cy="12" r="3" /><path d="M12 2.8v2.4M12 18.8v2.4M2.8 12h2.4M18.8 12h2.4M5.5 5.5l1.7 1.7M16.8 16.8l1.7 1.7M5.5 18.5l1.7-1.7M16.8 7.2l1.7-1.7" /><circle cx="12" cy="12" r="6.6" /></>,
close: <path d="M6 6l12 12M18 6 6 18" />,
centrifuge: <><circle cx="12" cy="12" r="8.5" /><circle cx="12" cy="12" r="1.8" /><path d="M12 6.5v.01M17 14.5v.01M7 14.5v.01" /></>,
} as const;

Expand Down
26 changes: 26 additions & 0 deletions frontend/app/components/roleTag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { actorRoleLabels } from "../lib/labels";
import { type CategoryTone, Icon, type IconName } from "./icons";

/**
* One icon and colour per role, shared by the login page's demo cards and the audit
* log's role tags. A role missing here falls back to grey; tests/login-audit.test.mjs
* fails if one is missing.
*/
export const ROLE_STYLES: Record<string, { icon: IconName; tone: CategoryTone }> = {
STUDENT: { icon: "cap", tone: "orange" },
TEACHER: { icon: "user", tone: "blue" },
TECHNICIAN: { icon: "wrench", tone: "green" },
ADMIN: { icon: "gear", tone: "violet" },
SYSTEM: { icon: "bolt", tone: "graphite" },
};

export function roleStyle(role: string) {
return ROLE_STYLES[role] ?? { icon: "user" as IconName, tone: "graphite" as CategoryTone };
}

export function RoleTag({ role }: { role: string }) {
const style = roleStyle(role);
return <span className={`role-tag tone-${style.tone}`} data-role-tag={role}>
<Icon name={style.icon} size={14} />{actorRoleLabels[role] ?? role}
</span>;
}
6 changes: 3 additions & 3 deletions frontend/app/components/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { statusLabels } from "../lib/labels";
import type { ToastState } from "../lib/types";
import { Icon } from "./icons";

export function Brand({ light = false, mobile = false }: { light?: boolean; mobile?: boolean }) {
return <div className={`${mobile ? "mobile-brand " : ""}brand ${light ? "light" : ""}`}>
export function Brand({ light = false }: { light?: boolean }) {
return <div className={`brand ${light ? "light" : ""}`}>
<span className="brand-mark" aria-hidden="true">
<svg viewBox="0 0 32 32" width="32" height="32">
<path d="M9 3h10l8 6v12l-8 8H9l-6-6V9z" fill="var(--orange)" />
Expand All @@ -25,7 +25,7 @@ export function Modal({ title, subtitle, onClose, children }: { title: string; s
<section className="modal" role="dialog" aria-modal="true" aria-label={title}>
<header>
<div><h2>{title}</h2><p>{subtitle}</p></div>
<button className="icon-button" onClick={onClose} aria-label="关闭">×</button>
<button type="button" className="icon-button" onClick={onClose} aria-label="关闭"><Icon name="close" size={18} /></button>
</header>
{children}
</section>
Expand Down
114 changes: 107 additions & 7 deletions frontend/app/components/views/AuditView.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,113 @@
import { actionLabels, formatDate } from "../../lib/labels";
import {
type ActionDomain, actionDomain, actionDomainLabels, actionLabels, actorRoleLabels, readableDetails, targetTypeLabels,
} from "../../lib/labels";
import type { AuditLog, PageData } from "../../lib/types";
import { Empty, StatusBadge } from "../ui";
import { type CategoryTone, Icon, type IconName } from "../icons";
import { RoleTag } from "../roleTag";
import { Empty } from "../ui";

export function AuditView({ auditLogs }: { auditLogs: PageData<AuditLog> | null }) {
export type AuditFilter = { actorRole: string; action: string };
export const emptyAuditFilter: AuditFilter = { actorRole: "", action: "" };

const DOMAIN_STYLES: Record<ActionDomain, { icon: IconName; tone: CategoryTone }> = {
equipment: { icon: "box", tone: "violet" },
reservation: { icon: "calendar", tone: "orange" },
workorder: { icon: "wrench", tone: "green" },
payment: { icon: "receipt", tone: "blue" },
};

/**
* Filter options come from the label tables, never from the rows on screen, so an
* action stays selectable after filtering or paging (see AGENTS.md).
*/
const ACTION_GROUPS = (Object.keys(actionDomainLabels) as ActionDomain[]).map((domain) => ({
domain,
actions: Object.keys(actionLabels).filter((action) => actionDomain(action) === domain),
}));

function splitDateTime(value: string) {
const date = new Date(value);
const pad = (n: number) => String(n).padStart(2, "0");
return {
day: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
time: `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`,
};
}

export function AuditView({ auditLogs, filter, onFilterChange, onSearch, onReset, onPage }: {
auditLogs: PageData<AuditLog> | null;
filter: AuditFilter;
onFilterChange: (filter: AuditFilter) => void;
onSearch: () => void;
onReset: () => void;
onPage: (page: number) => void;
}) {
const page = auditLogs?.page ?? 0;
const totalPages = auditLogs?.totalPages ?? 0;
return <div className="view-stack">
<section className="panel table-panel">
<header><h3>操作审计日志</h3><small>仅管理员可见 · {auditLogs?.totalElements ?? 0} 条</small></header>
<div className="timeline">{auditLogs?.content.map((item) => <article key={item.id}><span className="timeline-dot"></span><div><header><b>{actionLabels[item.action] ?? item.action}</b><StatusBadge value={item.actorRole} /></header><p>{item.details}</p><small>{item.actorUsername} · {item.targetType} #{item.targetId ?? "—"} · {formatDate(item.createdAt)}</small></div></article>)}{!auditLogs?.content.length && <Empty text="暂无审计记录" />}</div>
<section className="hero page-hero">
<div className="hero-copy">
<i className="hero-rule" aria-hidden="true" />
<h2>操作审计</h2>
<p>记录系统关键操作,便于追踪、复核与合规管理。</p>
</div>
<p className="hero-motto" aria-hidden="true">
可追溯、可审计,<br />让实验室运行更规范。
<small>BETTER LABS<br />BRIGHTER DISCOVERIES</small>
</p>
</section>

<section className="panel table-panel audit-panel">
<form className="audit-filters" onSubmit={(event) => { event.preventDefault(); onSearch(); }}>
<label>角色筛选
<select data-audit-filter="role" value={filter.actorRole} onChange={(e) => onFilterChange({ ...filter, actorRole: e.target.value })}>
<option value="">全部角色</option>
{Object.entries(actorRoleLabels).map(([role, label]) => <option key={role} value={role}>{label}</option>)}
</select>
</label>
<label>操作类型
<select data-audit-filter="action" value={filter.action} onChange={(e) => onFilterChange({ ...filter, action: e.target.value })}>
<option value="">全部操作类型</option>
{ACTION_GROUPS.map((group) => <optgroup key={group.domain} label={actionDomainLabels[group.domain]}>
{group.actions.map((action) => <option key={action} value={action}>{actionLabels[action]}</option>)}
</optgroup>)}
</select>
</label>
<div className="spacer" />
<button type="submit" className="solid-button" data-action="search">查询</button>
<button type="button" className="ghost-button" data-action="reset" onClick={onReset}>重置</button>
</form>

{Boolean(auditLogs?.content.length) && <div className="table-scroll"><table className="list-table audit-table">
<thead><tr><th>操作时间</th><th>操作</th><th>角色</th><th>操作详情</th><th>操作人</th></tr></thead>
<tbody>{auditLogs?.content.map((item) => {
const when = splitDateTime(item.createdAt);
const style = DOMAIN_STYLES[actionDomain(item.action)];
const target = targetTypeLabels[item.targetType] ?? item.targetType;
return <tr key={item.id} data-audit-id={item.id}>
<td className="audit-time"><i aria-hidden="true" /><b>{when.day}</b><small>{when.time}</small></td>
<td><div className="audit-action">
<span className={`action-icon tone-${style.tone}`}><Icon name={style.icon} size={20} /></span>
<b>{actionLabels[item.action] ?? item.action}</b>
</div></td>
<td><RoleTag role={item.actorRole} /></td>
<td><div className="fault-cell audit-detail">
<b>{readableDetails(item.details)}</b>
<small>{target}{item.targetId != null ? ` #${item.targetId}` : ""}</small>
</div></td>
<td><div className="audit-actor">
<span className="avatar" aria-hidden="true">{item.actorRole === "SYSTEM" ? <Icon name="bolt" size={16} /> : item.actorUsername.slice(0, 1).toUpperCase()}</span>
<b>{item.actorUsername}</b>
</div></td>
</tr>;
})}</tbody>
</table></div>}
{!auditLogs?.content.length && <Empty text="没有符合条件的审计记录" />}
{Boolean(auditLogs?.totalElements) && <footer className="pager">
<small>共 {auditLogs?.totalElements} 条 · 第 {page + 1} / {Math.max(totalPages, 1)} 页</small>
<button type="button" className="ghost-button" data-action="prev-page" disabled={page <= 0} onClick={() => onPage(page - 1)}>上一页</button>
<button type="button" className="ghost-button" data-action="next-page" disabled={page + 1 >= totalPages} onClick={() => onPage(page + 1)}>下一页</button>
</footer>}
</section>
</div>;
}

Loading
Loading