feat: build collaboration workspaces

This commit is contained in:
2026-07-03 03:02:36 -07:00
parent bf3dd256ab
commit 4ba2a11b88
53 changed files with 9557 additions and 29 deletions

View File

@@ -0,0 +1,325 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import { AlertTriangle, CheckCircle2, Search, Wrench, XCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog } from "@/components/ui/dialog";
import { Input, Textarea } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
import type { ApiResult } from "@/modules/core/server/api";
import type { DeviceAsset, DeviceAssetInput, DeviceAssetStatus, DeviceOperationsData, MaintenanceTicket, MaintenanceTicketInput, MaintenanceTicketPriority, MaintenanceTicketStatus } from "@/modules/devices/types";
import { DEVICE_ASSET_STATUS_LABELS, DEVICE_ASSET_STATUS_VALUES, MAINTENANCE_TICKET_PRIORITY_LABELS, MAINTENANCE_TICKET_PRIORITY_VALUES, MAINTENANCE_TICKET_STATUS_LABELS, MAINTENANCE_TICKET_STATUS_VALUES } from "@/modules/devices/types";
type DevicesWorkspaceClientProps = {
canManage: boolean;
initialData: DeviceOperationsData;
};
type DialogState =
| { kind: "device"; mode: "create"; value: DeviceAssetInput }
| { kind: "device"; mode: "edit"; id: string; value: DeviceAssetInput }
| { kind: "ticket"; mode: "create"; value: MaintenanceTicketInput }
| { kind: "ticket"; mode: "edit"; id: string; value: MaintenanceTicketInput };
const emptyDevice: DeviceAssetInput = {
name: "",
code: "",
category: "",
location: "",
status: "active",
lastInspectedAt: undefined,
notes: "",
};
const emptyTicket: MaintenanceTicketInput = {
deviceId: undefined,
title: "",
description: "",
priority: "normal",
status: "open",
assigneeLabel: "",
resolutionNotes: "",
};
function formatDateTime(value: string | undefined): string {
return value ? new Date(value).toLocaleString("zh-CN") : "-";
}
function deviceToInput(device: DeviceAsset): DeviceAssetInput {
return {
name: device.name,
code: device.code,
category: device.category,
location: device.location,
status: device.status,
lastInspectedAt: device.lastInspectedAt,
notes: device.notes,
};
}
function ticketToInput(ticket: MaintenanceTicket): MaintenanceTicketInput {
return {
deviceId: ticket.deviceId,
title: ticket.title,
description: ticket.description,
priority: ticket.priority,
status: ticket.status,
assigneeLabel: ticket.assigneeLabel,
resolutionNotes: ticket.resolutionNotes,
};
}
function badgeVariant(status: string): "danger" | "secondary" | "success" | "warning" {
if (status === "active" || status === "resolved" || status === "closed") {
return "success";
}
if (status === "maintenance" || status === "open" || status === "assigned" || status === "urgent") {
return "warning";
}
if (status === "disabled" || status === "retired" || status === "cancelled") {
return "danger";
}
return "secondary";
}
export function DevicesWorkspaceClient({ canManage, initialData }: DevicesWorkspaceClientProps): React.ReactElement {
const [data, setData] = useState(initialData);
const [query, setQuery] = useState("");
const [deviceStatus, setDeviceStatus] = useState<"all" | DeviceAssetStatus>("all");
const [ticketStatus, setTicketStatus] = useState<"all" | MaintenanceTicketStatus>("all");
const [dialog, setDialog] = useState<DialogState | undefined>();
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const normalizedQuery = query.trim().toLowerCase();
const filteredDevices = useMemo(
() =>
data.devices.filter((device) => {
const matchesQuery = !normalizedQuery || [device.name, device.code, device.category, device.location, device.notes].join(" ").toLowerCase().includes(normalizedQuery);
const matchesStatus = deviceStatus === "all" || device.status === deviceStatus;
return matchesQuery && matchesStatus;
}),
[data.devices, deviceStatus, normalizedQuery],
);
const filteredTickets = useMemo(
() =>
data.tickets.filter((ticket) => {
const matchesQuery = !normalizedQuery || [ticket.title, ticket.description, ticket.deviceName, ticket.assigneeLabel].join(" ").toLowerCase().includes(normalizedQuery);
const matchesStatus = ticketStatus === "all" || ticket.status === ticketStatus;
return matchesQuery && matchesStatus;
}),
[data.tickets, normalizedQuery, ticketStatus],
);
async function refreshData(): Promise<void> {
const response = await fetch("/api/devices/assets");
const result = (await response.json()) as ApiResult<{ data: DeviceOperationsData }>;
if (result.success) {
setData(result.data);
}
}
async function submitDialog(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!dialog || !canManage) {
setMessage("当前角色无权维护设备运维");
return;
}
const isCreate = dialog.mode === "create";
const path =
dialog.kind === "device"
? isCreate
? "/api/devices/assets"
: `/api/devices/assets/${dialog.id}`
: isCreate
? "/api/devices/tickets"
: `/api/devices/tickets/${dialog.id}`;
setIsPending(true);
const response = await fetch(path, {
method: isCreate ? "POST" : "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(dialog.value),
});
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
setDialog(undefined);
await refreshData();
}
}
async function remove(kind: "device" | "ticket", id: string): Promise<void> {
if (!canManage) {
setMessage("当前角色无权删除设备运维记录");
return;
}
const path = kind === "device" ? `/api/devices/assets/${id}` : `/api/devices/tickets/${id}`;
setIsPending(true);
const response = await fetch(path, { method: "DELETE" });
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
await refreshData();
}
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={CheckCircle2} label="运行设备" value={data.metrics.activeDevices} />
<MetricCard icon={Wrench} label="维护中设备" value={data.metrics.maintenanceDevices} />
<MetricCard icon={AlertTriangle} label="待处理工单" value={data.metrics.openTickets} />
<MetricCard icon={XCircle} label="紧急工单" value={data.metrics.urgentTickets} />
</section>
<section className="flex flex-col gap-3 border-b pb-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:flex xl:items-center">
<label className="relative block sm:col-span-2 xl:col-span-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input className="w-full pl-9 xl:w-72" onChange={(event) => setQuery(event.target.value)} placeholder="搜索设备、工单或位置" value={query} />
</label>
<Select aria-label="设备状态" onValueChange={(value) => setDeviceStatus(value as "all" | DeviceAssetStatus)} options={[{ value: "all", label: "全部设备状态" }, ...DEVICE_ASSET_STATUS_VALUES.map((value) => ({ value, label: DEVICE_ASSET_STATUS_LABELS[value] }))]} value={deviceStatus} />
<Select aria-label="工单状态" onValueChange={(value) => setTicketStatus(value as "all" | MaintenanceTicketStatus)} options={[{ value: "all", label: "全部工单状态" }, ...MAINTENANCE_TICKET_STATUS_VALUES.map((value) => ({ value, label: MAINTENANCE_TICKET_STATUS_LABELS[value] }))]} value={ticketStatus} />
<Button disabled={!canManage} onClick={() => setDialog({ kind: "device", mode: "create", value: emptyDevice })} type="button"></Button>
<Button disabled={!canManage} onClick={() => setDialog({ kind: "ticket", mode: "create", value: emptyTicket })} type="button" variant="outline"></Button>
</div>
</section>
{message ? <p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">{message}</p> : null}
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[900px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredDevices.map((device) => (
<Table.Row key={device.id}>
<Table.Cell className="px-4 py-3"><p className="font-medium">{device.name}</p><p className="text-xs text-muted-foreground">{device.code} / {device.notes || "-"}</p></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{device.category || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{device.location || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(device.status)}>{DEVICE_ASSET_STATUS_LABELS[device.status]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(device.lastInspectedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3"><div className="flex justify-end gap-2"><Button disabled={!canManage || isPending} onClick={() => setDialog({ kind: "device", mode: "edit", id: device.id, value: deviceToInput(device) })} size="sm" type="button" variant="outline"></Button><Button disabled={!canManage || isPending} onClick={() => remove("device", device.id)} size="sm" type="button" variant="outline"></Button></div></Table.Cell>
</Table.Row>
))}
{filteredDevices.length === 0 ? <Table.Row><Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}></Table.Cell></Table.Row> : null}
</Table.Body>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[980px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredTickets.map((ticket) => (
<Table.Row key={ticket.id}>
<Table.Cell className="px-4 py-3"><p className="font-medium">{ticket.title}</p><p className="text-xs text-muted-foreground">{ticket.description || ticket.resolutionNotes || "-"}</p></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{ticket.deviceName || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(ticket.priority)}>{MAINTENANCE_TICKET_PRIORITY_LABELS[ticket.priority]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(ticket.status)}>{MAINTENANCE_TICKET_STATUS_LABELS[ticket.status]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{ticket.assigneeLabel || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(ticket.updatedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3"><div className="flex justify-end gap-2"><Button disabled={!canManage || isPending} onClick={() => setDialog({ kind: "ticket", mode: "edit", id: ticket.id, value: ticketToInput(ticket) })} size="sm" type="button" variant="outline"></Button><Button disabled={!canManage || isPending} onClick={() => remove("ticket", ticket.id)} size="sm" type="button" variant="outline"></Button></div></Table.Cell>
</Table.Row>
))}
{filteredTickets.length === 0 ? <Table.Row><Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={7}></Table.Cell></Table.Row> : null}
</Table.Body>
</Table>
</CardContent>
</Card>
<Dialog onClose={() => setDialog(undefined)} open={dialog !== undefined} title={dialog?.kind === "device" ? "设备台账" : "维修工单"} width="lg">
{dialog ? (
<form className="grid gap-3" onSubmit={submitDialog}>
{dialog.kind === "device" ? <DeviceForm dialog={dialog} setDialog={setDialog} /> : <TicketForm devices={data.devices} dialog={dialog} setDialog={setDialog} />}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={() => setDialog(undefined)} type="button" variant="outline"></Button>
<Button disabled={isPending || !canManage} type="submit"></Button>
</div>
</form>
) : null}
</Dialog>
</div>
);
}
function DeviceForm({ dialog, setDialog }: { dialog: Extract<DialogState, { kind: "device" }>; setDialog: (dialog: DialogState) => void }): React.ReactElement {
const value = dialog.value;
function update(next: Partial<DeviceAssetInput>): void {
setDialog({ ...dialog, value: { ...value, ...next } });
}
return (
<>
<Input label="设备名称" onChange={(event) => update({ name: event.target.value })} required value={value.name} />
<Input label="设备编号" onChange={(event) => update({ code: event.target.value })} required value={value.code} />
<Input label="分类" onChange={(event) => update({ category: event.target.value })} value={value.category} />
<Input label="位置" onChange={(event) => update({ location: event.target.value })} value={value.location} />
<Select aria-label="设备状态" onValueChange={(status) => update({ status: status as DeviceAssetStatus })} options={DEVICE_ASSET_STATUS_VALUES.map((status) => ({ value: status, label: DEVICE_ASSET_STATUS_LABELS[status] }))} value={value.status} />
<Input label="最近巡检时间" onChange={(event) => update({ lastInspectedAt: event.target.value || undefined })} type="datetime-local" value={value.lastInspectedAt ? value.lastInspectedAt.slice(0, 16) : ""} />
<Textarea label="备注" onChange={(event) => update({ notes: event.target.value })} value={value.notes} />
</>
);
}
function TicketForm({ devices, dialog, setDialog }: { devices: DeviceAsset[]; dialog: Extract<DialogState, { kind: "ticket" }>; setDialog: (dialog: DialogState) => void }): React.ReactElement {
const value = dialog.value;
function update(next: Partial<MaintenanceTicketInput>): void {
setDialog({ ...dialog, value: { ...value, ...next } });
}
return (
<>
<Select aria-label="关联设备" onValueChange={(deviceId) => update({ deviceId: deviceId || undefined })} options={[{ value: "", label: "不关联设备" }, ...devices.map((device) => ({ value: device.id, label: `${device.name} / ${device.code}` }))]} value={value.deviceId ?? ""} />
<Input label="工单标题" onChange={(event) => update({ title: event.target.value })} required value={value.title} />
<Textarea label="问题描述" onChange={(event) => update({ description: event.target.value })} value={value.description} />
<Select aria-label="优先级" onValueChange={(priority) => update({ priority: priority as MaintenanceTicketPriority })} options={MAINTENANCE_TICKET_PRIORITY_VALUES.map((priority) => ({ value: priority, label: MAINTENANCE_TICKET_PRIORITY_LABELS[priority] }))} value={value.priority} />
<Select aria-label="工单状态" onValueChange={(status) => update({ status: status as MaintenanceTicketStatus })} options={MAINTENANCE_TICKET_STATUS_VALUES.map((status) => ({ value: status, label: MAINTENANCE_TICKET_STATUS_LABELS[status] }))} value={value.status} />
<Input label="负责人" onChange={(event) => update({ assigneeLabel: event.target.value })} value={value.assigneeLabel} />
<Textarea label="处理记录" onChange={(event) => update({ resolutionNotes: event.target.value })} value={value.resolutionNotes} />
</>
);
}
function MetricCard({ icon: Icon, label, value }: { icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>; label: string; value: number }): React.ReactElement {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between p-4">
<div><p className="text-sm text-muted-foreground">{label}</p><CardTitle className="mt-2 text-3xl">{value}</CardTitle></div>
<Icon className="size-5 text-primary" aria-hidden={true} />
</CardHeader>
</Card>
);
}

View File

@@ -0,0 +1,218 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import { getDatabase } from "@/modules/core/server/db";
import { deviceAssets, maintenanceTickets } from "@/modules/core/server/schema";
import type { DeviceAsset, DeviceAssetInput, DeviceOperationsData, MaintenanceTicket, MaintenanceTicketInput } from "@/modules/devices/types";
export type DeviceMutationFailure = {
success: false;
reason: string;
status: number;
};
export function isDeviceMutationFailure(value: unknown): value is DeviceMutationFailure {
return (
typeof value === "object" &&
value !== null &&
"success" in value &&
(value as { success?: unknown }).success === false
);
}
function iso(value: Date): string {
return value.toISOString();
}
function optionalIso(value: Date | null): string | undefined {
return value ? iso(value) : undefined;
}
function toDeviceAsset(row: typeof deviceAssets.$inferSelect): DeviceAsset {
return {
id: row.id,
organizationId: row.organizationId,
name: row.name,
code: row.code,
category: row.category,
location: row.location,
status: row.status,
lastInspectedAt: optionalIso(row.lastInspectedAt),
notes: row.notes,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
function toMaintenanceTicket(row: typeof maintenanceTickets.$inferSelect, deviceNameById: Map<string, string>): MaintenanceTicket {
const deviceId = row.deviceId ?? undefined;
return {
id: row.id,
organizationId: row.organizationId,
deviceId,
deviceName: deviceId ? deviceNameById.get(deviceId) ?? "" : "",
title: row.title,
description: row.description,
priority: row.priority,
status: row.status,
assigneeLabel: row.assigneeLabel,
resolutionNotes: row.resolutionNotes,
resolvedAt: optionalIso(row.resolvedAt),
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
export async function listDeviceOperationsData(organizationId: string): Promise<DeviceOperationsData> {
const database = getDatabase();
const [deviceRows, ticketRows] = await Promise.all([
database.select().from(deviceAssets).where(eq(deviceAssets.organizationId, organizationId)).orderBy(desc(deviceAssets.updatedAt)),
database.select().from(maintenanceTickets).where(eq(maintenanceTickets.organizationId, organizationId)).orderBy(desc(maintenanceTickets.updatedAt)),
]);
const deviceNameById = new Map(deviceRows.map((device) => [device.id, device.name]));
return {
metrics: {
activeDevices: deviceRows.filter((device) => device.status === "active").length,
maintenanceDevices: deviceRows.filter((device) => device.status === "maintenance").length,
openTickets: ticketRows.filter((ticket) => ticket.status === "open" || ticket.status === "assigned").length,
urgentTickets: ticketRows.filter((ticket) => ticket.priority === "urgent").length,
},
devices: deviceRows.map(toDeviceAsset),
tickets: ticketRows.map((ticket) => toMaintenanceTicket(ticket, deviceNameById)),
};
}
export async function createDeviceAsset(input: DeviceAssetInput & { organizationId: string }): Promise<DeviceAsset | DeviceMutationFailure> {
const database = getDatabase();
const rows = await database
.insert(deviceAssets)
.values({
organizationId: input.organizationId,
name: input.name,
code: input.code,
category: input.category,
location: input.location,
status: input.status,
lastInspectedAt: input.lastInspectedAt ? new Date(input.lastInspectedAt) : undefined,
notes: input.notes,
})
.returning();
const device = rows[0];
return device ? toDeviceAsset(device) : { success: false, reason: "设备创建失败", status: 500 };
}
export async function updateDeviceAsset(input: DeviceAssetInput & { id: string; organizationId: string }): Promise<DeviceAsset | DeviceMutationFailure> {
const database = getDatabase();
const rows = await database
.update(deviceAssets)
.set({
name: input.name,
code: input.code,
category: input.category,
location: input.location,
status: input.status,
lastInspectedAt: input.lastInspectedAt ? new Date(input.lastInspectedAt) : null,
notes: input.notes,
updatedAt: new Date(),
})
.where(and(eq(deviceAssets.id, input.id), eq(deviceAssets.organizationId, input.organizationId)))
.returning();
const device = rows[0];
return device ? toDeviceAsset(device) : { success: false, reason: "设备不存在", status: 404 };
}
export async function deleteDeviceAsset(input: { id: string; organizationId: string }): Promise<DeviceAsset | DeviceMutationFailure> {
const database = getDatabase();
const rows = await database
.delete(deviceAssets)
.where(and(eq(deviceAssets.id, input.id), eq(deviceAssets.organizationId, input.organizationId)))
.returning();
const device = rows[0];
return device ? toDeviceAsset(device) : { success: false, reason: "设备不存在", status: 404 };
}
async function getDeviceNameById(organizationId: string, deviceId: string | undefined): Promise<Map<string, string> | DeviceMutationFailure> {
if (!deviceId) {
return new Map();
}
const database = getDatabase();
const rows = await database
.select({ id: deviceAssets.id, name: deviceAssets.name })
.from(deviceAssets)
.where(and(eq(deviceAssets.id, deviceId), eq(deviceAssets.organizationId, organizationId)));
const device = rows[0];
return device ? new Map([[device.id, device.name]]) : { success: false, reason: "设备不存在", status: 404 };
}
export async function createMaintenanceTicket(input: MaintenanceTicketInput & { organizationId: string }): Promise<MaintenanceTicket | DeviceMutationFailure> {
const deviceNameById = await getDeviceNameById(input.organizationId, input.deviceId);
if (isDeviceMutationFailure(deviceNameById)) {
return deviceNameById;
}
const database = getDatabase();
const resolvedAt = input.status === "resolved" || input.status === "closed" ? new Date() : undefined;
const rows = await database
.insert(maintenanceTickets)
.values({
organizationId: input.organizationId,
deviceId: input.deviceId,
title: input.title,
description: input.description,
priority: input.priority,
status: input.status,
assigneeLabel: input.assigneeLabel,
resolutionNotes: input.resolutionNotes,
resolvedAt,
})
.returning();
const ticket = rows[0];
return ticket ? toMaintenanceTicket(ticket, deviceNameById) : { success: false, reason: "维修工单创建失败", status: 500 };
}
export async function updateMaintenanceTicket(input: MaintenanceTicketInput & { id: string; organizationId: string }): Promise<MaintenanceTicket | DeviceMutationFailure> {
const deviceNameById = await getDeviceNameById(input.organizationId, input.deviceId);
if (isDeviceMutationFailure(deviceNameById)) {
return deviceNameById;
}
const database = getDatabase();
const resolvedAt = input.status === "resolved" || input.status === "closed" ? new Date() : null;
const rows = await database
.update(maintenanceTickets)
.set({
deviceId: input.deviceId ?? null,
title: input.title,
description: input.description,
priority: input.priority,
status: input.status,
assigneeLabel: input.assigneeLabel,
resolutionNotes: input.resolutionNotes,
resolvedAt,
updatedAt: new Date(),
})
.where(and(eq(maintenanceTickets.id, input.id), eq(maintenanceTickets.organizationId, input.organizationId)))
.returning();
const ticket = rows[0];
return ticket ? toMaintenanceTicket(ticket, deviceNameById) : { success: false, reason: "维修工单不存在", status: 404 };
}
export async function deleteMaintenanceTicket(input: { id: string; organizationId: string }): Promise<MaintenanceTicket | DeviceMutationFailure> {
const database = getDatabase();
const rows = await database
.delete(maintenanceTickets)
.where(and(eq(maintenanceTickets.id, input.id), eq(maintenanceTickets.organizationId, input.organizationId)))
.returning();
const ticket = rows[0];
if (!ticket) {
return { success: false, reason: "维修工单不存在", status: 404 };
}
const deviceIds = ticket.deviceId ? [ticket.deviceId] : [];
const deviceRows =
deviceIds.length > 0
? await database.select({ id: deviceAssets.id, name: deviceAssets.name }).from(deviceAssets).where(inArray(deviceAssets.id, deviceIds))
: [];
return toMaintenanceTicket(ticket, new Map(deviceRows.map((device) => [device.id, device.name])));
}

200
modules/devices/types.ts Normal file
View File

@@ -0,0 +1,200 @@
export const DEVICE_ASSET_STATUS_VALUES = ["active", "maintenance", "disabled", "retired"] as const;
export type DeviceAssetStatus = (typeof DEVICE_ASSET_STATUS_VALUES)[number];
export const MAINTENANCE_TICKET_STATUS_VALUES = ["open", "assigned", "resolved", "closed", "cancelled"] as const;
export type MaintenanceTicketStatus = (typeof MAINTENANCE_TICKET_STATUS_VALUES)[number];
export const MAINTENANCE_TICKET_PRIORITY_VALUES = ["low", "normal", "high", "urgent"] as const;
export type MaintenanceTicketPriority = (typeof MAINTENANCE_TICKET_PRIORITY_VALUES)[number];
export const DEVICE_ASSET_STATUS_LABELS: Record<DeviceAssetStatus, string> = {
active: "运行中",
maintenance: "维护中",
disabled: "停用",
retired: "已退役",
};
export const MAINTENANCE_TICKET_STATUS_LABELS: Record<MaintenanceTicketStatus, string> = {
open: "待处理",
assigned: "已派单",
resolved: "已解决",
closed: "已关闭",
cancelled: "已取消",
};
export const MAINTENANCE_TICKET_PRIORITY_LABELS: Record<MaintenanceTicketPriority, string> = {
low: "低",
normal: "普通",
high: "高",
urgent: "紧急",
};
export type DeviceAsset = {
id: string;
organizationId: string;
name: string;
code: string;
category: string;
location: string;
status: DeviceAssetStatus;
lastInspectedAt?: string;
notes: string;
createdAt: string;
updatedAt: string;
};
export type MaintenanceTicket = {
id: string;
organizationId: string;
deviceId?: string;
deviceName: string;
title: string;
description: string;
priority: MaintenanceTicketPriority;
status: MaintenanceTicketStatus;
assigneeLabel: string;
resolutionNotes: string;
resolvedAt?: string;
createdAt: string;
updatedAt: string;
};
export type DeviceOperationsData = {
metrics: {
activeDevices: number;
maintenanceDevices: number;
openTickets: number;
urgentTickets: number;
};
devices: DeviceAsset[];
tickets: MaintenanceTicket[];
};
export type DeviceAssetInput = {
name: string;
code: string;
category: string;
location: string;
status: DeviceAssetStatus;
lastInspectedAt?: string;
notes: string;
};
export type MaintenanceTicketInput = {
deviceId?: string;
title: string;
description: string;
priority: MaintenanceTicketPriority;
status: MaintenanceTicketStatus;
assigneeLabel: string;
resolutionNotes: string;
};
type ValidationSuccess<T> = {
success: true;
data: T;
};
type ValidationFailure = {
success: false;
reason: string;
};
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readString(source: Record<string, unknown>, key: string): string {
const value = source[key];
return typeof value === "string" ? value.trim() : "";
}
function readOptionalString(source: Record<string, unknown>, key: string): string | undefined {
const value = readString(source, key);
return value || undefined;
}
function readEnum<T extends string>(value: unknown, values: readonly T[]): T | null {
return typeof value === "string" ? values.find((item) => item === value) ?? null : null;
}
function validateIsoDate(value: string | undefined, label: string): ValidationResult<string | undefined> {
if (!value) {
return { success: true, data: undefined };
}
return Number.isNaN(new Date(value).getTime())
? { success: false, reason: `${label}无效` }
: { success: true, data: value };
}
export function validateDeviceAssetInput(value: unknown): ValidationResult<DeviceAssetInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const name = readString(value, "name");
const code = readString(value, "code");
if (!name || !code) {
return { success: false, reason: "设备名称和编号不能为空" };
}
const status = readEnum(value.status, DEVICE_ASSET_STATUS_VALUES);
if (!status) {
return { success: false, reason: "设备状态无效" };
}
const lastInspectedAt = validateIsoDate(readOptionalString(value, "lastInspectedAt"), "最近巡检时间");
if (!lastInspectedAt.success) {
return lastInspectedAt;
}
return {
success: true,
data: {
name,
code,
category: readString(value, "category"),
location: readString(value, "location"),
status,
lastInspectedAt: lastInspectedAt.data,
notes: readString(value, "notes"),
},
};
}
export function validateMaintenanceTicketInput(value: unknown): ValidationResult<MaintenanceTicketInput> {
if (!isRecord(value)) {
return { success: false, reason: "请求数据格式无效" };
}
const title = readString(value, "title");
if (!title) {
return { success: false, reason: "工单标题不能为空" };
}
const priority = readEnum(value.priority, MAINTENANCE_TICKET_PRIORITY_VALUES);
if (!priority) {
return { success: false, reason: "工单优先级无效" };
}
const status = readEnum(value.status, MAINTENANCE_TICKET_STATUS_VALUES);
if (!status) {
return { success: false, reason: "工单状态无效" };
}
return {
success: true,
data: {
deviceId: readOptionalString(value, "deviceId"),
title,
description: readString(value, "description"),
priority,
status,
assigneeLabel: readString(value, "assigneeLabel"),
resolutionNotes: readString(value, "resolutionNotes"),
},
};
}