update:Dialog替换

This commit is contained in:
xiaozhu
2026-08-26 16:48:19 +08:00
parent d7f3db1cc4
commit e326cc1a93
16 changed files with 738 additions and 675 deletions
@@ -0,0 +1,90 @@
import type { ReactNode } from "react"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { cn } from "~/lib/utils"
/** 与系统管理弹窗 footer 一致的次要按钮样式 */
export const dialogSecondaryButtonClass =
"h-[37px] gap-[7px] rounded-md border-[#d8e1e9] bg-white px-[15px] text-xs font-[650] text-[#56687c]"
/** 与系统管理弹窗 footer 一致的主按钮样式 */
export const dialogPrimaryButtonClass =
"h-[37px] gap-[7px] rounded-md border border-[#126ac3] bg-gradient-to-br from-[#1881e7] to-[#1268c9] px-[15px] text-xs font-[650] text-white shadow-[0_6px_15px_rgb(20_111_202/18%)] enabled:hover:from-[#1175d8] enabled:hover:to-[#0e5bad]"
const EYEBROW_CLASS =
"text-[9px] font-extrabold tracking-[0.12em] text-[#2d82d4]"
const TITLE_CLASS = "mt-1 text-[19px] font-normal text-[#1c2d42]"
type AppFormDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
eyebrow?: string
title?: ReactNode
titleId?: string
children: ReactNode
footer?: ReactNode
contentClassName?: string
bodyClassName?: string
showCloseButton?: boolean
size?: "compact" | "panel"
}
function AppFormDialog({
open,
onOpenChange,
eyebrow,
title,
titleId,
children,
footer,
contentClassName,
bodyClassName,
showCloseButton = true,
size = "compact",
}: AppFormDialogProps) {
const maxWidth =
size === "panel"
? "sm:max-w-[min(520px,calc(100vw-40px))]"
: "sm:max-w-[min(480px,calc(100vw-40px))]"
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={showCloseButton}
className={cn(
"flex max-h-[80vh] flex-col gap-0 overflow-hidden rounded-[11px] border-[#dce4eb] bg-white p-0 text-[#1c2d42] shadow-[0_24px_70px_rgb(8_27_48/25%)] ring-0",
maxWidth,
contentClassName,
)}
>
{eyebrow || title ? (
<DialogHeader className="shrink-0 space-y-0 border-b border-[#e7ecf1] px-[22px] pb-4 pt-5 text-left">
{eyebrow ? <span className={EYEBROW_CLASS}>{eyebrow}</span> : null}
{title ? (
<DialogTitle id={titleId} className={TITLE_CLASS}>
{title}
</DialogTitle>
) : null}
</DialogHeader>
) : null}
<div className={cn("min-h-0 flex-1 overflow-y-auto", bodyClassName)}>
{children}
</div>
{footer ? (
<DialogFooter className="mt-0 shrink-0 flex-row justify-end gap-2 border-t border-[#e8edf2] bg-white px-[22px] py-3.5 sm:justify-end">
{footer}
</DialogFooter>
) : null}
</DialogContent>
</Dialog>
)
}
export { AppFormDialog }
@@ -0,0 +1,74 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog"
import { cn } from "~/lib/utils"
import {
dialogPrimaryButtonClass,
dialogSecondaryButtonClass,
} from "./AppFormDialog"
type ConfirmDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
onConfirm: () => void | Promise<void>
}
function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = "确定",
cancelLabel = "取消",
destructive = false,
onConfirm,
}: ConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="rounded-[11px] border-[#dce4eb] bg-white">
<AlertDialogHeader>
<AlertDialogTitle className="text-[#1c2d42]">{title}</AlertDialogTitle>
<AlertDialogDescription className="text-[#56687c]">
{description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
variant="outline"
size="sm"
className={dialogSecondaryButtonClass}
>
{cancelLabel}
</AlertDialogCancel>
<AlertDialogAction
variant={destructive ? "destructive" : "default"}
size="sm"
className={cn(
destructive
? "h-[37px] gap-[7px] rounded-md border-0 bg-[#d75b5b] px-[15px] text-xs font-[650] text-white hover:bg-[#a94f4f]"
: dialogPrimaryButtonClass,
)}
onClick={() => void onConfirm()}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export { ConfirmDialog }
@@ -1,25 +1,16 @@
import { X } from "lucide-react";
import { type Employee, type Workspace } from "../../services/api"; import { type Employee, type Workspace } from "../../services/api";
import {
AppFormDialog,
dialogPrimaryButtonClass,
dialogSecondaryButtonClass,
} from "~/components/common/AppFormDialog";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
formFieldClass, formFieldClass,
modalBackdropClass,
modalCompactClass,
modalEyebrowClass,
modalFooterClass,
modalFormClass, modalFormClass,
modalHeaderClass,
modalTitleClass,
} from "../platform/modalUi"; } from "../platform/modalUi";
import { UserMultiSelect } from "./UserMultiSelect"; import { UserMultiSelect } from "./UserMultiSelect";
const CLOSE_BUTTON_CLASS =
"size-[34px] rounded-[7px] border border-[#dfe6ed] bg-white hover:bg-[#f7faff] hover:border-[#b9c9da]";
const SECONDARY_BUTTON_CLASS =
"h-[37px] gap-[7px] rounded-md border-[#d8e1e9] bg-white px-[15px] text-xs font-[650] text-[#56687c]";
const PRIMARY_BUTTON_CLASS =
"h-[37px] gap-[7px] rounded-md border border-[#126ac3] bg-gradient-to-br from-[#1881e7] to-[#1268c9] px-[15px] text-xs font-[650] text-white shadow-[0_6px_15px_rgb(20_111_202/18%)] enabled:hover:from-[#1175d8] enabled:hover:to-[#0e5bad]";
export function ImportMemberDialog({ export function ImportMemberDialog({
open, open,
selectedProject, selectedProject,
@@ -27,7 +18,6 @@ export function ImportMemberDialog({
selectedUserIds, selectedUserIds,
existingMemberIds, existingMemberIds,
saving, saving,
onNotify,
onChangeSelectedUserIds, onChangeSelectedUserIds,
onSubmit, onSubmit,
onClose, onClose,
@@ -43,60 +33,49 @@ export function ImportMemberDialog({
onSubmit: () => Promise<void> | void; onSubmit: () => Promise<void> | void;
onClose: () => void; onClose: () => void;
}) { }) {
if (!open || !selectedProject) return null;
return ( return (
<div className={modalBackdropClass} role="presentation"> <AppFormDialog
<section className={modalCompactClass} role="dialog" aria-modal="true"> open={open && selectedProject !== null}
<div className={modalHeaderClass}> onOpenChange={(nextOpen) => {
<div> if (!nextOpen) onClose();
<span className={modalEyebrowClass}>IMPORT MEMBER</span> }}
<h2 className={modalTitleClass}> {selectedProject?.workspace_name ?? "项目"}</h2> eyebrow="IMPORT MEMBER"
</div> title={`导入成员到 ${selectedProject?.workspace_name ?? "项目"}`}
footer={
<>
<Button <Button
variant="ghost" variant="outline"
size="icon-sm" size="sm"
type="button" type="button"
aria-label="关闭"
onClick={() => onClose()} onClick={() => onClose()}
className={CLOSE_BUTTON_CLASS} className={dialogSecondaryButtonClass}
> >
<X size={16} />
</Button> </Button>
</div> <Button
<form className={modalFormClass}> variant="default"
<label className={`${formFieldClass} pb-[120px]`}> size="sm"
<span><span className="text-[#e74c3c]">*</span></span> type="button"
<UserMultiSelect disabled={saving || selectedUserIds.length === 0}
users={availableUsers} onClick={() => void onSubmit()}
selectedUserIds={selectedUserIds} className={dialogPrimaryButtonClass}
onChange={onChangeSelectedUserIds} >
existingMemberIds={existingMemberIds} {saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
/> </Button>
</label> </>
<div className={modalFooterClass}> }
<Button >
variant="outline" <div className={modalFormClass}>
size="sm" <label className={`${formFieldClass} pb-[120px]`}>
type="button" <span><span className="text-[#e74c3c]">*</span></span>
onClick={() => onClose()} <UserMultiSelect
className={SECONDARY_BUTTON_CLASS} users={availableUsers}
> selectedUserIds={selectedUserIds}
onChange={onChangeSelectedUserIds}
</Button> existingMemberIds={existingMemberIds}
<Button />
variant="default" </label>
size="sm" </div>
type="button" </AppFormDialog>
disabled={saving || selectedUserIds.length === 0}
onClick={() => void onSubmit()}
className={PRIMARY_BUTTON_CLASS}
>
{saving ? "添加中…" : `添加 (${selectedUserIds.length})`}
</Button>
</div>
</form>
</section>
</div>
); );
} }
@@ -1,26 +1,15 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { X } from "lucide-react";
import type { PlatformPermission, Role } from "../../services/api"; import type { PlatformPermission, Role } from "../../services/api";
import { Button } from "~/components/ui/button";
import { import {
modalBackdropClass, AppFormDialog,
modalCompactClass, dialogPrimaryButtonClass,
modalEyebrowClass, dialogSecondaryButtonClass,
modalFooterClass, } from "~/components/common/AppFormDialog";
modalFormClass, import { Button } from "~/components/ui/button";
modalHeaderClass, import { modalFormClass } from "../platform/modalUi";
modalTitleClass,
} from "../platform/modalUi";
import { PermissionCheckboxList } from "./PermissionCheckboxList"; import { PermissionCheckboxList } from "./PermissionCheckboxList";
const CLOSE_BUTTON_CLASS =
"size-[34px] rounded-[7px] border border-[#dfe6ed] bg-white hover:bg-[#f7faff] hover:border-[#b9c9da]";
const SECONDARY_BUTTON_CLASS =
"h-[37px] gap-[7px] rounded-md border-[#d8e1e9] bg-white px-[15px] text-xs font-[650] text-[#56687c]";
const PRIMARY_BUTTON_CLASS =
"h-[37px] gap-[7px] rounded-md border border-[#126ac3] bg-gradient-to-br from-[#1881e7] to-[#1268c9] px-[15px] text-xs font-[650] text-white shadow-[0_6px_15px_rgb(20_111_202/18%)] enabled:hover:from-[#1175d8] enabled:hover:to-[#0e5bad]";
export function PermissionEditDialog({ export function PermissionEditDialog({
open, open,
target, target,
@@ -41,7 +30,6 @@ export function PermissionEditDialog({
const [selectedCodes, setSelectedCodes] = useState<string[]>(() => { const [selectedCodes, setSelectedCodes] = useState<string[]>(() => {
if (!open || !target) return []; if (!open || !target) return [];
const initial = [...currentCodes]; const initial = [...currentCodes];
// admin 角色必须保留 system:view(后端守卫);前端锁定勾选兜底。
if (target.role_code === "admin" && !initial.includes("system:view")) { if (target.role_code === "admin" && !initial.includes("system:view")) {
initial.push("system:view"); initial.push("system:view");
} }
@@ -69,45 +57,22 @@ export function PermissionEditDialog({
); );
}; };
if (!open || !target) return null;
return ( return (
<div className={modalBackdropClass} role="presentation"> <AppFormDialog
<section className={modalCompactClass} role="dialog" aria-modal="true"> open={open && target !== null}
<div className={modalHeaderClass}> onOpenChange={(nextOpen) => {
<div> if (!nextOpen) onClose();
<span className={modalEyebrowClass}>PERMISSIONS</span> }}
<h2 className={modalTitleClass}> · {target.role_name}</h2> eyebrow="PERMISSIONS"
</div> title={target ? `权限配置 · ${target.role_name}` : "权限配置"}
<Button footer={
variant="ghost" <>
size="icon-sm"
type="button"
aria-label="关闭"
onClick={onClose}
className={CLOSE_BUTTON_CLASS}
>
<X size={16} />
</Button>
</div>
<div className={modalFormClass}>
{target.role_code === "admin" && (
<p className="role-error">admin system:view </p>
)}
<PermissionCheckboxList
permissions={permissions}
selected={selectedCodes}
targetRoleCode={target.role_code}
onToggle={togglePermission}
/>
</div>
<div className={modalFooterClass}>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
type="button" type="button"
onClick={onClose} onClick={onClose}
className={SECONDARY_BUTTON_CLASS} className={dialogSecondaryButtonClass}
> >
</Button> </Button>
@@ -117,12 +82,26 @@ export function PermissionEditDialog({
type="button" type="button"
disabled={saving} disabled={saving}
onClick={() => onSubmit(selectedCodes)} onClick={() => onSubmit(selectedCodes)}
className={PRIMARY_BUTTON_CLASS} className={dialogPrimaryButtonClass}
> >
{saving ? "保存中…" : "保存权限"} {saving ? "保存中…" : "保存权限"}
</Button> </Button>
</div> </>
</section> }
</div> >
<div className={modalFormClass}>
{target?.role_code === "admin" && (
<p className="role-error">admin system:view </p>
)}
{target ? (
<PermissionCheckboxList
permissions={permissions}
selected={selectedCodes}
targetRoleCode={target.role_code}
onToggle={togglePermission}
/>
) : null}
</div>
</AppFormDialog>
); );
} }
@@ -1,25 +1,17 @@
import { X } from "lucide-react";
import { type Workspace } from "../../services/api"; import { type Workspace } from "../../services/api";
import {
AppFormDialog,
dialogPrimaryButtonClass,
dialogSecondaryButtonClass,
} from "~/components/common/AppFormDialog";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea"; import { Textarea } from "~/components/ui/textarea";
import { import {
formFieldClass, formFieldClass,
modalBackdropClass,
modalCompactClass,
modalEyebrowClass,
modalFooterClass,
modalFormClass, modalFormClass,
modalHeaderClass,
modalTitleClass,
} from "../platform/modalUi"; } from "../platform/modalUi";
const CLOSE_BUTTON_CLASS =
"size-[34px] rounded-[7px] border border-[#dfe6ed] bg-white hover:bg-[#f7faff] hover:border-[#b9c9da]";
const SECONDARY_BUTTON_CLASS =
"h-[37px] gap-[7px] rounded-md border-[#d8e1e9] bg-white px-[15px] text-xs font-[650] text-[#56687c]";
const PRIMARY_BUTTON_CLASS =
"h-[37px] gap-[7px] rounded-md border border-[#126ac3] bg-gradient-to-br from-[#1881e7] to-[#1268c9] px-[15px] text-xs font-[650] text-white shadow-[0_6px_15px_rgb(20_111_202/18%)] enabled:hover:from-[#1175d8] enabled:hover:to-[#0e5bad]";
const FORM_INPUT_CLASS = const FORM_INPUT_CLASS =
"h-[39px] w-full rounded-md border border-[#d6e0e9] bg-white px-[11px] text-xs text-[#283a4e] outline-none focus:border-[#5b9ddb] focus:shadow-[0_0_0_3px_rgb(38_125_207/8%)] disabled:cursor-not-allowed disabled:opacity-50"; "h-[39px] w-full rounded-md border border-[#d6e0e9] bg-white px-[11px] text-xs text-[#283a4e] outline-none focus:border-[#5b9ddb] focus:shadow-[0_0_0_3px_rgb(38_125_207/8%)] disabled:cursor-not-allowed disabled:opacity-50";
const FORM_TEXTAREA_CLASS = const FORM_TEXTAREA_CLASS =
@@ -30,7 +22,6 @@ export function ProjectEditDialog({
editingProject, editingProject,
projectForm, projectForm,
saving, saving,
onNotify,
onChangeForm, onChangeForm,
onSubmit, onSubmit,
onClose, onClose,
@@ -44,91 +35,78 @@ export function ProjectEditDialog({
onSubmit: (event: React.FormEvent) => Promise<void> | void; onSubmit: (event: React.FormEvent) => Promise<void> | void;
onClose: () => void; onClose: () => void;
}) { }) {
if (!open) return null;
return ( return (
<div className={modalBackdropClass} role="presentation"> <AppFormDialog
<section className={modalCompactClass} role="dialog" aria-modal="true"> open={open}
<div className={modalHeaderClass}> onOpenChange={(nextOpen) => {
<div> if (!nextOpen) onClose();
<span className={modalEyebrowClass}>PROJECT</span> }}
<h2 className={modalTitleClass}>{editingProject ? "编辑项目" : "新建项目"}</h2> eyebrow="PROJECT"
</div> title={editingProject ? "编辑项目" : "新建项目"}
>
<form className={modalFormClass} onSubmit={(event) => void onSubmit(event)}>
{!editingProject && (
<label className={formFieldClass}>
<span><span className="text-[#e74c3c]">*</span></span>
<Input
className={FORM_INPUT_CLASS}
autoFocus
value={projectForm.workspace_code}
onChange={(event) => onChangeForm({ ...projectForm, workspace_code: event.target.value })}
placeholder="例如:model-development(小写字母、数字、连字符,3-32 字符)"
/>
</label>
)}
<label className={formFieldClass}>
<span><span className="text-[#e74c3c]">*</span></span>
<Input
className={FORM_INPUT_CLASS}
value={projectForm.workspace_name}
onChange={(event) => onChangeForm({ ...projectForm, workspace_name: event.target.value })}
placeholder="请输入项目名称"
/>
</label>
<label className={formFieldClass}>
<span></span>
<Input
className={FORM_INPUT_CLASS}
type="number"
value={projectForm.quota_bytes}
onChange={(event) => onChangeForm({ ...projectForm, quota_bytes: parseInt(event.target.value) || 0 })}
placeholder="0 表示无配额限制"
/>
</label>
<label className={formFieldClass}>
<span></span>
<Textarea
className={FORM_TEXTAREA_CLASS}
value={projectForm.description}
onChange={(event) => onChangeForm({ ...projectForm, description: event.target.value })}
placeholder="请输入项目描述(可选)"
rows={4}
/>
</label>
<div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-[#e8edf2] bg-white -mx-[22px] px-[22px] py-3.5">
<Button <Button
variant="ghost" variant="outline"
size="icon-sm" size="sm"
type="button" type="button"
aria-label="关闭"
onClick={() => onClose()} onClick={() => onClose()}
className={CLOSE_BUTTON_CLASS} className={dialogSecondaryButtonClass}
> >
<X size={16} />
</Button>
<Button
variant="default"
size="sm"
type="submit"
disabled={saving}
className={dialogPrimaryButtonClass}
>
{saving ? "保存中…" : "保存"}
</Button> </Button>
</div> </div>
<form className={modalFormClass} onSubmit={(event) => void onSubmit(event)}> </form>
{!editingProject && ( </AppFormDialog>
<label className={formFieldClass}>
<span><span className="text-[#e74c3c]">*</span></span>
<Input
className={FORM_INPUT_CLASS}
autoFocus
value={projectForm.workspace_code}
onChange={(event) => onChangeForm({ ...projectForm, workspace_code: event.target.value })}
placeholder="例如:model-development(小写字母、数字、连字符,3-32 字符)"
/>
</label>
)}
<label className={formFieldClass}>
<span><span className="text-[#e74c3c]">*</span></span>
<Input
className={FORM_INPUT_CLASS}
value={projectForm.workspace_name}
onChange={(event) => onChangeForm({ ...projectForm, workspace_name: event.target.value })}
placeholder="请输入项目名称"
/>
</label>
<label className={formFieldClass}>
<span></span>
<Input
className={FORM_INPUT_CLASS}
type="number"
value={projectForm.quota_bytes}
onChange={(event) => onChangeForm({ ...projectForm, quota_bytes: parseInt(event.target.value) || 0 })}
placeholder="0 表示无配额限制"
/>
</label>
<label className={formFieldClass}>
<span></span>
<Textarea
className={FORM_TEXTAREA_CLASS}
value={projectForm.description}
onChange={(event) => onChangeForm({ ...projectForm, description: event.target.value })}
placeholder="请输入项目描述(可选)"
rows={4}
/>
</label>
<div className={modalFooterClass}>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => onClose()}
className={SECONDARY_BUTTON_CLASS}
>
</Button>
<Button
variant="default"
size="sm"
type="submit"
disabled={saving}
className={PRIMARY_BUTTON_CLASS}
>
{saving ? "保存中…" : "保存"}
</Button>
</div>
</form>
</section>
</div>
); );
} }
@@ -4,6 +4,7 @@ import { Plus, Search } from "lucide-react";
import { ApiRequestError, type Employee, type Workspace, type WorkspaceMember } from "../../services/api"; import { ApiRequestError, type Employee, type Workspace, type WorkspaceMember } from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext"; import { useApi, useAuth } from "../../context/AuthContext";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { ConfirmDialog } from "~/components/common/ConfirmDialog";
import { import {
Table, Table,
TableBody, TableBody,
@@ -52,6 +53,7 @@ export function ProjectManagementPage({
const [membersDrawerOpen, setMembersDrawerOpen] = useState(false); const [membersDrawerOpen, setMembersDrawerOpen] = useState(false);
const [currentProjectMembers, setCurrentProjectMembers] = useState<WorkspaceMember[]>([]); const [currentProjectMembers, setCurrentProjectMembers] = useState<WorkspaceMember[]>([]);
const [membersLoading, setMembersLoading] = useState(false); const [membersLoading, setMembersLoading] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null);
const canManage = user?.role_code === "admin"; const canManage = user?.role_code === "admin";
@@ -138,8 +140,7 @@ export function ProjectManagementPage({
} }
}; };
const deleteProject = async (project: Workspace): Promise<void> => { const executeDeleteProject = async (project: Workspace): Promise<void> => {
if (!window.confirm(`确定要删除项目"${project.workspace_name}"吗?此操作将级联软删所有成员。`)) return;
try { try {
await api.deleteWorkspace(project.workspace_id); await api.deleteWorkspace(project.workspace_id);
setProjects((current) => current.filter((p) => p.workspace_id !== project.workspace_id)); setProjects((current) => current.filter((p) => p.workspace_id !== project.workspace_id));
@@ -332,7 +333,7 @@ export function ProjectManagementPage({
className="is-danger" className="is-danger"
disabled={!canManage || project.status === "disabled"} disabled={!canManage || project.status === "disabled"}
title={project.status === "disabled" ? "已删除的项目不能操作" : "删除项目"} title={project.status === "disabled" ? "已删除的项目不能操作" : "删除项目"}
onClick={() => void deleteProject(project)} onClick={() => setDeleteTarget(project)}
> >
</button> </button>
@@ -382,6 +383,26 @@ export function ProjectManagementPage({
onRemoveMember={(userId) => void removeMember(userId)} onRemoveMember={(userId) => void removeMember(userId)}
onNotify={onNotify} onNotify={onNotify}
/> />
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) setDeleteTarget(null);
}}
title="确定删除项目?"
description={
deleteTarget
? `确定要删除项目"${deleteTarget.workspace_name}"吗?此操作将级联软删所有成员。`
: ""
}
confirmLabel="删除"
destructive
onConfirm={async () => {
if (!deleteTarget) return;
await executeDeleteProject(deleteTarget);
setDeleteTarget(null);
}}
/>
</section> </section>
); );
} }
+83 -112
View File
@@ -1,24 +1,22 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { X } from "lucide-react";
import type { import type {
PlatformPermission, PlatformPermission,
Role, Role,
RoleCreatePayload, RoleCreatePayload,
RoleUpdatePayload, RoleUpdatePayload,
} from "../../services/api"; } from "../../services/api";
import {
AppFormDialog,
dialogPrimaryButtonClass,
dialogSecondaryButtonClass,
} from "~/components/common/AppFormDialog";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
formFieldClass, formFieldClass,
formInputClass, formInputClass,
formTextareaClass, formTextareaClass,
modalBackdropClass,
modalCompactClass,
modalEyebrowClass,
modalFooterClass,
modalFormClass, modalFormClass,
modalHeaderClass,
modalTitleClass,
} from "../platform/modalUi"; } from "../platform/modalUi";
import { PermissionCheckboxList } from "./PermissionCheckboxList"; import { PermissionCheckboxList } from "./PermissionCheckboxList";
@@ -39,7 +37,6 @@ const EMPTY_FORM: RoleFormState = {
permission_codes: [], permission_codes: [],
}; };
/** 实时校验 role_code:空值不报错,提交时由必填拦截兜底。 */
function validateRoleCode(value: string): string { function validateRoleCode(value: string): string {
const code = value.trim(); const code = value.trim();
if (!code) return ""; if (!code) return "";
@@ -58,13 +55,6 @@ function validateRoleCode(value: string): string {
return ""; return "";
} }
const CLOSE_BUTTON_CLASS =
"size-[34px] rounded-[7px] border border-[#dfe6ed] bg-white hover:bg-[#f7faff] hover:border-[#b9c9da]";
const SECONDARY_BUTTON_CLASS =
"h-[37px] gap-[7px] rounded-md border-[#d8e1e9] bg-white px-[15px] text-xs font-[650] text-[#56687c]";
const PRIMARY_BUTTON_CLASS =
"h-[37px] gap-[7px] rounded-md border border-[#126ac3] bg-gradient-to-br from-[#1881e7] to-[#1268c9] px-[15px] text-xs font-[650] text-white shadow-[0_6px_15px_rgb(20_111_202/18%)] enabled:hover:from-[#1175d8] enabled:hover:to-[#0e5bad]";
export function RoleEditDialog({ export function RoleEditDialog({
open, open,
editing, editing,
@@ -117,7 +107,6 @@ export function RoleEditDialog({
}; };
const toggleCreatePermission = (code: string): void => { const toggleCreatePermission = (code: string): void => {
// 新建角色为非 admin,system:* 由后端守卫禁止;此处防御性拦截
if (code.startsWith("system:")) return; if (code.startsWith("system:")) return;
setForm((current) => ({ setForm((current) => ({
...current, ...current,
@@ -143,7 +132,6 @@ export function RoleEditDialog({
} }
const payload: RoleCreatePayload | RoleUpdatePayload = editing const payload: RoleCreatePayload | RoleUpdatePayload = editing
? { ? {
// 内置角色 role_name 锁定;description 可改
...(editing.is_builtin ? {} : { role_name: form.role_name.trim() }), ...(editing.is_builtin ? {} : { role_name: form.role_name.trim() }),
description: form.description.trim() || null, description: form.description.trim() || null,
} }
@@ -156,108 +144,91 @@ export function RoleEditDialog({
onSubmit(payload); onSubmit(payload);
}; };
if (!open) return null;
return ( return (
<div className={modalBackdropClass} role="presentation"> <AppFormDialog
<section className={modalCompactClass} role="dialog" aria-modal="true"> open={open}
<div className={modalHeaderClass}> onOpenChange={(nextOpen) => {
<div> if (!nextOpen) onClose();
<span className={modalEyebrowClass}>ROLE</span> }}
<h2 className={modalTitleClass}>{editing ? "编辑角色" : "新建角色"}</h2> eyebrow="ROLE"
</div> title={editing ? "编辑角色" : "新建角色"}
<Button >
variant="ghost" <form className={`${modalFormClass} role-create-form`} onSubmit={(event) => void handleSubmit(event)}>
size="icon-sm" {editing ? (
type="button"
aria-label="关闭"
onClick={onClose}
className={CLOSE_BUTTON_CLASS}
>
<X size={16} />
</Button>
</div>
<form className={`${modalFormClass} role-create-form`} onSubmit={(event) => void handleSubmit(event)}>
{editing ? (
<label className={formFieldClass}>
<span></span>
<input
className={formInputClass}
disabled
value={form.role_code}
/>
</label>
) : (
<label className={formFieldClass}>
<span><span className="text-[#e74c3c]">*</span></span>
<input
className={formInputClass}
autoFocus
value={form.role_code}
onChange={handleRoleCodeChange}
placeholder="例如:qa_engineer"
/>
<span className="form-hint">
//线/2~64
</span>
{roleCodeError && <span className="role-error">{roleCodeError}</span>}
</label>
)}
<label className={formFieldClass}> <label className={formFieldClass}>
<span><span className="text-[#e74c3c]">*</span></span> <span></span>
<input className={formInputClass} disabled value={form.role_code} />
</label>
) : (
<label className={formFieldClass}>
<span><span className="text-[#e74c3c]">*</span></span>
<input <input
className={formInputClass} className={formInputClass}
value={form.role_name} autoFocus
disabled={editing !== null && editing.is_builtin} value={form.role_code}
onChange={(event) => setForm({ ...form, role_name: event.target.value })} onChange={handleRoleCodeChange}
placeholder="请输入角色名称" placeholder="例如:qa_engineer"
/> />
{editing !== null && editing.is_builtin && ( <span className="form-hint">
<span className="form-hint"></span> //线/2~64
)} </span>
{roleCodeError && <span className="role-error">{roleCodeError}</span>}
</label> </label>
<label className={formFieldClass}> )}
<span></span> <label className={formFieldClass}>
<textarea <span><span className="text-[#e74c3c]">*</span></span>
className={formTextareaClass} <input
value={form.description} className={formInputClass}
onChange={(event) => setForm({ ...form, description: event.target.value })} value={form.role_name}
placeholder="请输入角色描述(可选)" disabled={editing !== null && editing.is_builtin}
/> onChange={(event) => setForm({ ...form, role_name: event.target.value })}
</label> placeholder="请输入角色名称"
{!editing && ( />
<div className="permission-groups"> {editing !== null && editing.is_builtin && (
<div className="permission-group__title"></div> <span className="form-hint"></span>
<PermissionCheckboxList
permissions={permissions}
selected={form.permission_codes}
targetRoleCode=""
onToggle={toggleCreatePermission}
/>
</div>
)} )}
<div className={modalFooterClass}> </label>
<Button <label className={formFieldClass}>
variant="outline" <span></span>
size="sm" <textarea
type="button" className={formTextareaClass}
onClick={onClose} value={form.description}
className={SECONDARY_BUTTON_CLASS} onChange={(event) => setForm({ ...form, description: event.target.value })}
> placeholder="请输入角色描述(可选)"
/>
</Button> </label>
<Button {!editing && (
variant="default" <div className="permission-groups">
size="sm" <div className="permission-group__title"></div>
type="submit" <PermissionCheckboxList
disabled={saving} permissions={permissions}
className={PRIMARY_BUTTON_CLASS} selected={form.permission_codes}
> targetRoleCode=""
{saving ? "保存中…" : "保存"} onToggle={toggleCreatePermission}
</Button> />
</div> </div>
</form> )}
</section> <div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-[#e8edf2] bg-white -mx-[22px] px-[22px] py-3.5">
</div> <Button
variant="outline"
size="sm"
type="button"
onClick={onClose}
className={dialogSecondaryButtonClass}
>
</Button>
<Button
variant="default"
size="sm"
type="submit"
disabled={saving}
className={dialogPrimaryButtonClass}
>
{saving ? "保存中…" : "保存"}
</Button>
</div>
</form>
</AppFormDialog>
); );
} }
@@ -10,6 +10,7 @@ import {
} from "../../services/api"; } from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext"; import { useApi, useAuth } from "../../context/AuthContext";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { ConfirmDialog } from "~/components/common/ConfirmDialog";
import { PermissionEditDialog } from "./PermissionEditDialog"; import { PermissionEditDialog } from "./PermissionEditDialog";
import { RoleEditDialog } from "./RoleEditDialog"; import { RoleEditDialog } from "./RoleEditDialog";
import { import {
@@ -50,6 +51,7 @@ export function RoleManagementPage({
// 权限配置弹窗 // 权限配置弹窗
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
const [permissionTarget, setPermissionTarget] = useState<Role | null>(null); const [permissionTarget, setPermissionTarget] = useState<Role | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Role | null>(null);
const canManage = user?.role_code === "admin"; const canManage = user?.role_code === "admin";
@@ -150,9 +152,7 @@ export function RoleManagementPage({
} }
}; };
const removeRole = async (role: Role): Promise<void> => { const executeRemoveRole = async (role: Role): Promise<void> => {
if (role.is_builtin) return;
if (!window.confirm(`确定删除角色"${role.role_name}"吗?`)) return;
try { try {
await api.deletePlatformRole(role.role_code); await api.deletePlatformRole(role.role_code);
setRoles((current) => current.filter( setRoles((current) => current.filter(
@@ -271,7 +271,7 @@ export function RoleManagementPage({
type="button" type="button"
disabled={!canManage || role.is_builtin} disabled={!canManage || role.is_builtin}
title={role.is_builtin ? "内置角色不可删除" : "删除角色"} title={role.is_builtin ? "内置角色不可删除" : "删除角色"}
onClick={() => void removeRole(role)} onClick={() => setDeleteTarget(role)}
className="h-6 gap-1 rounded-[4px] border-red-200 bg-white px-[9px] text-[11px] text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-[0.45]" className="h-6 gap-1 rounded-[4px] border-red-200 bg-white px-[9px] text-[11px] text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-[0.45]"
> >
@@ -306,6 +306,26 @@ export function RoleManagementPage({
onClose={() => setPermissionDialogOpen(false)} onClose={() => setPermissionDialogOpen(false)}
/> />
)} )}
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) setDeleteTarget(null);
}}
title="确定删除角色?"
description={
deleteTarget
? `确定删除角色"${deleteTarget.role_name}"吗?`
: ""
}
confirmLabel="删除"
destructive
onConfirm={async () => {
if (!deleteTarget || deleteTarget.is_builtin) return;
await executeRemoveRole(deleteTarget);
setDeleteTarget(null);
}}
/>
</section> </section>
); );
} }
@@ -1,8 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Plus, Search, X } from "lucide-react"; import { Plus, Search } from "lucide-react";
import { ApiRequestError, type Employee, type Role } from "../../services/api"; import { ApiRequestError, type Employee, type Role } from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext"; import { useApi, useAuth } from "../../context/AuthContext";
import {
AppFormDialog,
dialogPrimaryButtonClass,
dialogSecondaryButtonClass,
} from "~/components/common/AppFormDialog";
import { ConfirmDialog } from "~/components/common/ConfirmDialog";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { import {
@@ -17,13 +23,7 @@ import { AdminColgroup, USER_PROJECT_COL_WIDTHS } from "./AdminTable";
import { import {
formFieldClass, formFieldClass,
formInputClass, formInputClass,
modalBackdropClass,
modalCompactClass,
modalEyebrowClass,
modalFooterClass,
modalFormClass, modalFormClass,
modalHeaderClass,
modalTitleClass,
} from "../platform/modalUi"; } from "../platform/modalUi";
import "../../styles/admin.css"; import "../../styles/admin.css";
@@ -37,10 +37,6 @@ const EMPTY_FORM = {
status: "active" as "active" | "disabled" | "locked", status: "active" as "active" | "disabled" | "locked",
}; };
const CLOSE_BUTTON_CLASS =
"size-[34px] rounded-[7px] border border-[#dfe6ed] bg-white hover:bg-[#f7faff] hover:border-[#b9c9da]";
const SECONDARY_BUTTON_CLASS =
"h-[37px] gap-[7px] rounded-md border-[#d8e1e9] bg-white px-[15px] text-xs font-[650] text-[#56687c]";
const PRIMARY_BUTTON_CLASS = const PRIMARY_BUTTON_CLASS =
"h-[37px] gap-[7px] rounded-md border border-[#126ac3] bg-gradient-to-br from-[#1881e7] to-[#1268c9] px-[15px] text-xs font-[650] text-white shadow-[0_6px_15px_rgb(20_111_202/18%)] enabled:hover:from-[#1175d8] enabled:hover:to-[#0e5bad]"; "h-[37px] gap-[7px] rounded-md border border-[#126ac3] bg-gradient-to-br from-[#1881e7] to-[#1268c9] px-[15px] text-xs font-[650] text-white shadow-[0_6px_15px_rgb(20_111_202/18%)] enabled:hover:from-[#1175d8] enabled:hover:to-[#0e5bad]";
const FORM_INPUT_CLASS = const FORM_INPUT_CLASS =
@@ -67,6 +63,7 @@ export function UserManagementPage({
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM); const [form, setForm] = useState(EMPTY_FORM);
const [userSearchTerm, setUserSearchTerm] = useState(""); const [userSearchTerm, setUserSearchTerm] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Employee | null>(null);
const canManage = user?.role_code === "admin"; const canManage = user?.role_code === "admin";
@@ -179,8 +176,7 @@ export function UserManagementPage({
} }
}; };
const remove = async (employee: Employee): Promise<void> => { const executeRemove = async (employee: Employee): Promise<void> => {
if (!window.confirm(`确定从平台删除用户"${employee.display_name}"吗?`)) return;
try { try {
await api.deletePlatformEmployee(employee.user_id); await api.deletePlatformEmployee(employee.user_id);
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id)); setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
@@ -274,7 +270,7 @@ export function UserManagementPage({
type="button" type="button"
disabled={!canManage || isProtectedAdmin} disabled={!canManage || isProtectedAdmin}
title={isProtectedAdmin ? "管理员账号不能删除" : "删除"} title={isProtectedAdmin ? "管理员账号不能删除" : "删除"}
onClick={() => void remove(employee)} onClick={() => setDeleteTarget(employee)}
className={ROW_DANGER_BUTTON_CLASS} className={ROW_DANGER_BUTTON_CLASS}
> >
@@ -287,26 +283,15 @@ export function UserManagementPage({
</TableBody> </TableBody>
</Table> </Table>
{dialogOpen && ( <AppFormDialog
<div className={modalBackdropClass} role="presentation"> open={dialogOpen}
<section className={modalCompactClass} role="dialog" aria-modal="true"> onOpenChange={(nextOpen) => {
<div className={modalHeaderClass}> if (!nextOpen) setDialogOpen(false);
<div> }}
<span className={modalEyebrowClass}>EMPLOYEE</span> eyebrow="EMPLOYEE"
<h2 className={modalTitleClass}>{editing ? "编辑用户" : "添加用户"}</h2> title={editing ? "编辑用户" : "添加用户"}
</div> >
<Button <form className={modalFormClass} onSubmit={(event) => void submit(event)}>
variant="ghost"
size="icon-sm"
type="button"
aria-label="关闭"
onClick={() => setDialogOpen(false)}
className={CLOSE_BUTTON_CLASS}
>
<X size={16} />
</Button>
</div>
<form className={modalFormClass} onSubmit={(event) => void submit(event)}>
<label className={formFieldClass}> <label className={formFieldClass}>
<span><span className="text-[#e74c3c]">*</span></span> <span><span className="text-[#e74c3c]">*</span></span>
<Input <Input
@@ -388,13 +373,13 @@ export function UserManagementPage({
</select> </select>
</label> </label>
)} )}
<div className={modalFooterClass}> <div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-[#e8edf2] bg-white -mx-[22px] px-[22px] py-3.5">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
type="button" type="button"
onClick={() => setDialogOpen(false)} onClick={() => setDialogOpen(false)}
className={SECONDARY_BUTTON_CLASS} className={dialogSecondaryButtonClass}
> >
</Button> </Button>
@@ -403,15 +388,33 @@ export function UserManagementPage({
size="sm" size="sm"
type="submit" type="submit"
disabled={saving} disabled={saving}
className={PRIMARY_BUTTON_CLASS} className={dialogPrimaryButtonClass}
> >
{saving ? "保存中…" : "保存"} {saving ? "保存中…" : "保存"}
</Button> </Button>
</div> </div>
</form> </form>
</section> </AppFormDialog>
</div>
)} <ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) setDeleteTarget(null);
}}
title="确定删除用户?"
description={
deleteTarget
? `确定从平台删除用户"${deleteTarget.display_name}"吗?`
: ""
}
confirmLabel="删除"
destructive
onConfirm={async () => {
if (!deleteTarget) return;
await executeRemove(deleteTarget);
setDeleteTarget(null);
}}
/>
</section> </section>
); );
} }
@@ -1,19 +1,16 @@
import { type FormEvent } from "react"; import { type FormEvent } from "react";
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import {
AppFormDialog,
dialogPrimaryButtonClass,
dialogSecondaryButtonClass,
} from "~/components/common/AppFormDialog";
import { Button } from "~/components/ui/button";
import { import {
destinationChipClass, destinationChipClass,
formFieldClass, formFieldClass,
formInputClass, formInputClass,
iconButtonClass,
modalBackdropClass,
modalCompactClass,
modalEyebrowClass,
modalFooterClass,
modalFormClass, modalFormClass,
modalHeaderClass,
modalTitleClass,
primaryButtonClass,
secondaryButtonClass,
} from "./modalUi"; } from "./modalUi";
type CreateFolderModalProps = { type CreateFolderModalProps = {
@@ -35,26 +32,16 @@ export function CreateFolderModal({
onSubmit, onSubmit,
onClose, onClose,
}: CreateFolderModalProps) { }: CreateFolderModalProps) {
if (!open) return null;
return ( return (
<div className={modalBackdropClass} role="presentation"> <AppFormDialog
<section className={modalCompactClass} role="dialog" aria-modal="true"> open={open}
<div className={modalHeaderClass}> onOpenChange={(nextOpen) => {
<div> if (!nextOpen) onClose();
<span className={modalEyebrowClass}>WORKSPACE</span> }}
<h2 className={modalTitleClass}></h2> eyebrow="WORKSPACE"
</div> title="新建文件夹"
<button >
className={iconButtonClass} <form className={modalFormClass} onSubmit={onSubmit}>
type="button"
aria-label="关闭"
onClick={onClose}
>
<Icon name="close" />
</button>
</div>
<form className={modalFormClass} onSubmit={onSubmit}>
<div className={destinationChipClass}> <div className={destinationChipClass}>
<Icon name="folder" size={16} /> <Icon name="folder" size={16} />
{parentPath || "个人根目录"} {parentPath || "个人根目录"}
@@ -70,27 +57,30 @@ export function CreateFolderModal({
onChange={(event) => onNameChange(event.target.value)} onChange={(event) => onNameChange(event.target.value)}
/> />
</label> </label>
<div className={modalFooterClass}> <div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-[#e8edf2] bg-white -mx-[22px] px-[22px] py-3.5">
<button <Button
className={secondaryButtonClass} variant="outline"
size="sm"
type="button" type="button"
onClick={onClose} onClick={onClose}
className={dialogSecondaryButtonClass}
> >
</button> </Button>
<button <Button
className={primaryButtonClass} variant="default"
size="sm"
type="submit" type="submit"
disabled={busy || !name.trim()} disabled={busy || !name.trim()}
className={dialogPrimaryButtonClass}
> >
{busy {busy
? <span className="button-spinner" /> ? <span className="button-spinner" />
: <Icon name="folder" size={16} />} : <Icon name="folder" size={16} />}
{busy ? "正在创建…" : "创建文件夹"} {busy ? "正在创建…" : "创建文件夹"}
</button> </Button>
</div> </div>
</form> </form>
</section> </AppFormDialog>
</div>
); );
} }
@@ -1,21 +1,18 @@
import { type FormEvent } from "react"; import { type FormEvent } from "react";
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import type { ScriptType, Visibility } from "../../services/api"; import type { ScriptType, Visibility } from "../../services/api";
import {
AppFormDialog,
dialogPrimaryButtonClass,
dialogSecondaryButtonClass,
} from "~/components/common/AppFormDialog";
import { Button } from "~/components/ui/button";
import { import {
destinationChipClass, destinationChipClass,
formFieldClass, formFieldClass,
formHintClass, formHintClass,
formInputClass, formInputClass,
iconButtonClass,
modalBackdropClass,
modalEyebrowClass,
modalFooterClass,
modalFormClass, modalFormClass,
modalHeaderClass,
modalPanelClass,
modalTitleClass,
primaryButtonClass,
secondaryButtonClass,
} from "./modalUi"; } from "./modalUi";
type NewScriptForm = { type NewScriptForm = {
@@ -48,26 +45,17 @@ export function CreateScriptModal({
onSubmit, onSubmit,
onClose, onClose,
}: CreateScriptModalProps) { }: CreateScriptModalProps) {
if (!open) return null;
return ( return (
<div className={modalBackdropClass} role="presentation"> <AppFormDialog
<section className={modalPanelClass} role="dialog" aria-modal="true"> open={open}
<div className={modalHeaderClass}> onOpenChange={(nextOpen) => {
<div> if (!nextOpen) onClose();
<span className={modalEyebrowClass}></span> }}
<h2 className={modalTitleClass}></h2> eyebrow="工作副本"
</div> title="新建构建脚本"
<button size="panel"
className={iconButtonClass} >
type="button" <form className={modalFormClass} onSubmit={onSubmit}>
aria-label="关闭"
onClick={onClose}
>
<Icon name="close" />
</button>
</div>
<form className={modalFormClass} onSubmit={onSubmit}>
<div className={destinationChipClass}> <div className={destinationChipClass}>
<Icon name="folder" size={16} /> <Icon name="folder" size={16} />
{form.parentPath || "个人根目录"} {form.parentPath || "个人根目录"}
@@ -173,25 +161,28 @@ export function CreateScriptModal({
</select> </select>
</label> </label>
<div className={modalFooterClass}> <div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-[#e8edf2] bg-white -mx-[22px] px-[22px] py-3.5">
<button <Button
className={secondaryButtonClass} variant="outline"
size="sm"
type="button" type="button"
onClick={onClose} onClick={onClose}
className={dialogSecondaryButtonClass}
> >
</button> </Button>
<button <Button
className={primaryButtonClass} variant="default"
size="sm"
type="submit" type="submit"
disabled={creating || !form.name.trim()} disabled={creating || !form.name.trim()}
className={dialogPrimaryButtonClass}
> >
{creating ? <span className="button-spinner" /> : <Icon name="plus" size={16} />} {creating ? <span className="button-spinner" /> : <Icon name="plus" size={16} />}
{creating ? "正在创建…" : "创建脚本"} {creating ? "正在创建…" : "创建脚本"}
</button> </Button>
</div> </div>
</form> </form>
</section> </AppFormDialog>
</div>
); );
} }
@@ -1,21 +1,18 @@
import { type FormEvent } from "react"; import { type FormEvent } from "react";
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import type { Visibility } from "../../services/api"; import type { Visibility } from "../../services/api";
import {
AppFormDialog,
dialogPrimaryButtonClass,
dialogSecondaryButtonClass,
} from "~/components/common/AppFormDialog";
import { Button } from "~/components/ui/button";
import { import {
formFieldClass, formFieldClass,
formHintClass, formHintClass,
formInputClass, formInputClass,
formTextareaClass, formTextareaClass,
iconButtonClass,
modalBackdropClass,
modalCompactClass,
modalEyebrowClass,
modalFooterClass,
modalFormClass, modalFormClass,
modalHeaderClass,
modalTitleClass,
primaryButtonClass,
secondaryButtonClass,
} from "./modalUi"; } from "./modalUi";
type DataResourceUploadModalProps = { type DataResourceUploadModalProps = {
@@ -51,113 +48,106 @@ export function DataResourceUploadModal({
onSubmit, onSubmit,
onClose, onClose,
}: DataResourceUploadModalProps) { }: DataResourceUploadModalProps) {
if (!open) return null;
const finalTarget = targetPath.trim(); const finalTarget = targetPath.trim();
const finalPath = finalTarget const finalPath = finalTarget
? `${finalTarget}/${file?.name ?? ""}` ? `${finalTarget}/${file?.name ?? ""}`
: file?.name ?? ""; : file?.name ?? "";
return ( return (
<div className={modalBackdropClass} role="presentation"> <AppFormDialog
<section className={modalCompactClass} role="dialog" aria-modal="true"> open={open}
<div className={modalHeaderClass}> onOpenChange={(nextOpen) => {
<div> if (!nextOpen && !uploading) onClose();
<span className={modalEyebrowClass}></span> }}
<h2 className={modalTitleClass}></h2> eyebrow="数据资源"
title="上传数据文件"
>
<form className={modalFormClass} onSubmit={onSubmit}>
<label className={formFieldClass}>
<span></span>
<div className="font-normal text-[#283a4e]">
{file ? file.name : "未选择文件"}
</div> </div>
<button </label>
className={iconButtonClass} <label className={formFieldClass}>
type="button" <span></span>
aria-label="关闭" <input
onClick={onClose} className={formInputClass}
autoFocus
maxLength={255}
placeholder="例如:训练数据"
value={resourceName}
onChange={(event) => onNameChange(event.target.value)}
required
/>
</label>
<label className={formFieldClass}>
<span></span>
<input
className={formInputClass}
type="text"
maxLength={1024}
placeholder="例如:train/v1(留空上传到当前目录)"
value={targetPath}
onChange={(event) => onTargetPathChange(event.target.value)}
/>
<small className={formHintClass}>
{parentPath
? `当前位于 ${parentPath || "根目录"} · 最终路径:${finalPath || "—"}`
: `最终路径:${finalPath || "—"}`}
</small>
</label>
<label className={formFieldClass}>
<span></span>
<select
className={formInputClass}
value={visibility}
onChange={(event) =>
onVisibilityChange(event.target.value as Visibility)
}
> >
<Icon name="close" /> <option value="private"></option>
</button> <option value="workspace"></option>
<option value="public"></option>
</select>
</label>
<label className={formFieldClass}>
<span></span>
<textarea
className={formTextareaClass}
rows={3}
placeholder="可选描述"
value={description}
onChange={(event) => onDescriptionChange(event.target.value)}
/>
</label>
<div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-[#e8edf2] bg-white -mx-[22px] px-[22px] py-3.5">
<Button
variant="outline"
size="sm"
type="button"
onClick={onClose}
disabled={uploading}
className={dialogSecondaryButtonClass}
>
</Button>
<Button
variant="default"
size="sm"
type="submit"
disabled={uploading || !resourceName.trim()}
className={dialogPrimaryButtonClass}
>
{uploading ? (
<span className="button-spinner" />
) : (
<Icon name="upload" size={16} />
)}
{uploading ? "上传中…" : "上传"}
</Button>
</div> </div>
<form className={modalFormClass} onSubmit={onSubmit}> </form>
<label className={formFieldClass}> </AppFormDialog>
<span></span>
<div className="font-normal text-[#283a4e]">
{file ? file.name : "未选择文件"}
</div>
</label>
<label className={formFieldClass}>
<span></span>
<input
className={formInputClass}
autoFocus
maxLength={255}
placeholder="例如:训练数据"
value={resourceName}
onChange={(event) => onNameChange(event.target.value)}
required
/>
</label>
<label className={formFieldClass}>
<span></span>
<input
className={formInputClass}
type="text"
maxLength={1024}
placeholder="例如:train/v1(留空上传到当前目录)"
value={targetPath}
onChange={(event) => onTargetPathChange(event.target.value)}
/>
<small className={formHintClass}>
{parentPath
? `当前位于 ${parentPath || "根目录"} · 最终路径:${finalPath || "—"}`
: `最终路径:${finalPath || "—"}`}
</small>
</label>
<label className={formFieldClass}>
<span></span>
<select
className={formInputClass}
value={visibility}
onChange={(event) =>
onVisibilityChange(event.target.value as Visibility)
}
>
<option value="private"></option>
<option value="workspace"></option>
<option value="public"></option>
</select>
</label>
<label className={formFieldClass}>
<span></span>
<textarea
className={formTextareaClass}
rows={3}
placeholder="可选描述"
value={description}
onChange={(event) => onDescriptionChange(event.target.value)}
/>
</label>
<div className={modalFooterClass}>
<button
className={secondaryButtonClass}
type="button"
onClick={onClose}
disabled={uploading}
>
</button>
<button
className={primaryButtonClass}
type="submit"
disabled={uploading || !resourceName.trim()}
>
{uploading ? (
<span className="button-spinner" />
) : (
<Icon name="upload" size={16} />
)}
{uploading ? "上传中…" : "上传"}
</button>
</div>
</form>
</section>
</div>
); );
} }
+30 -37
View File
@@ -2,21 +2,18 @@ import { type FormEvent } from "react";
import Icon from "../../components/common/Icon"; import Icon from "../../components/common/Icon";
import type { ScriptItem, Visibility } from "../../services/api"; import type { ScriptItem, Visibility } from "../../services/api";
import { scriptIcon } from "../../features/platform/WorkspaceTree"; import { scriptIcon } from "../../features/platform/WorkspaceTree";
import {
AppFormDialog,
dialogPrimaryButtonClass,
dialogSecondaryButtonClass,
} from "~/components/common/AppFormDialog";
import { Button } from "~/components/ui/button";
import { import {
formFieldClass, formFieldClass,
formHintClass, formHintClass,
formInputClass, formInputClass,
formTextareaClass, formTextareaClass,
iconButtonClass,
modalBackdropClass,
modalEyebrowClass,
modalFooterClass,
modalFormClass, modalFormClass,
modalHeaderClass,
modalPanelClass,
modalTitleClass,
primaryButtonClass,
secondaryButtonClass,
} from "./modalUi"; } from "./modalUi";
type PublishModalProps = { type PublishModalProps = {
@@ -40,26 +37,18 @@ export function PublishModal({
onSubmit, onSubmit,
onClose, onClose,
}: PublishModalProps) { }: PublishModalProps) {
if (!publishTarget) return null;
return ( return (
<div className={modalBackdropClass} role="presentation"> <AppFormDialog
<section className={modalPanelClass} role="dialog" aria-modal="true"> open={publishTarget !== null}
<div className={modalHeaderClass}> onOpenChange={(nextOpen) => {
<div> if (!nextOpen) onClose();
<span className={modalEyebrowClass}></span> }}
<h2 className={modalTitleClass}></h2> eyebrow="不可变制品"
</div> title="发布稳定版本"
<button size="panel"
className={iconButtonClass} >
type="button" <form className={modalFormClass} onSubmit={onSubmit}>
aria-label="关闭" {publishTarget ? (
onClick={onClose}
>
<Icon name="close" />
</button>
</div>
<form className={modalFormClass} onSubmit={onSubmit}>
<div className="mb-[17px] flex items-center gap-2.5 rounded-[7px] border border-[#dce6ef] bg-[#f8fbfd] px-3 py-[11px]"> <div className="mb-[17px] flex items-center gap-2.5 rounded-[7px] border border-[#dce6ef] bg-[#f8fbfd] px-3 py-[11px]">
<span <span
className={`grid size-[25px] shrink-0 place-items-center rounded-[5px] ${ className={`grid size-[25px] shrink-0 place-items-center rounded-[5px] ${
@@ -79,6 +68,7 @@ export function PublishModal({
</small> </small>
</span> </span>
</div> </div>
) : null}
<label className={formFieldClass}> <label className={formFieldClass}>
<span></span> <span></span>
<textarea <textarea
@@ -105,27 +95,30 @@ export function PublishModal({
<option value="public"></option> <option value="public"></option>
</select> </select>
</label> </label>
<div className={modalFooterClass}> <div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-[#e8edf2] bg-white -mx-[22px] px-[22px] py-3.5">
<button <Button
className={secondaryButtonClass} variant="outline"
size="sm"
type="button" type="button"
onClick={onClose} onClick={onClose}
className={dialogSecondaryButtonClass}
> >
</button> </Button>
<button <Button
className={primaryButtonClass} variant="default"
size="sm"
type="submit" type="submit"
disabled={publishing} disabled={publishing}
className={dialogPrimaryButtonClass}
> >
{publishing {publishing
? <span className="button-spinner" /> ? <span className="button-spinner" />
: <Icon name="release" size={16} />} : <Icon name="release" size={16} />}
{publishing ? "正在发布…" : "确认发布"} {publishing ? "正在发布…" : "确认发布"}
</button> </Button>
</div> </div>
</form> </form>
</section> </AppFormDialog>
</div>
); );
} }
@@ -2,11 +2,11 @@ import Icon from "../../components/common/Icon";
import type { StableVersion } from "../../services/api"; import type { StableVersion } from "../../services/api";
import { copyToClipboard } from "../../lib/clipboard"; import { copyToClipboard } from "../../lib/clipboard";
import { import {
modalBackdropClass, AppFormDialog,
modalEyebrowClass, dialogPrimaryButtonClass,
modalPanelClass, } from "~/components/common/AppFormDialog";
primaryButtonClass, import { Button } from "~/components/ui/button";
} from "./modalUi"; import { modalEyebrowClass } from "./modalUi";
type VersionReceiptModalProps = { type VersionReceiptModalProps = {
publishedVersion: StableVersion | null; publishedVersion: StableVersion | null;
@@ -19,50 +19,51 @@ export function VersionReceiptModal({
onClose, onClose,
onCopy, onCopy,
}: VersionReceiptModalProps) { }: VersionReceiptModalProps) {
if (!publishedVersion) return null;
return ( return (
<div className={modalBackdropClass} role="presentation"> <AppFormDialog
<section open={publishedVersion !== null}
className={`${modalPanelClass} w-[min(470px,calc(100vw-40px))] px-7 py-[30px] pb-[25px] text-center`} onOpenChange={(nextOpen) => {
role="dialog" if (!nextOpen) onClose();
aria-modal="true" }}
> showCloseButton={false}
<div className="mx-auto mb-3.5 grid size-[58px] place-items-center rounded-full bg-gradient-to-br from-[#24b67d] to-[#149566] text-white shadow-[0_9px_24px_rgb(25_157_104/25%)]"> contentClassName="sm:max-w-[min(470px,calc(100vw-40px))] px-7 py-[30px] pb-[25px] text-center"
<Icon name="check" size={28} /> bodyClassName="overflow-visible"
</div> >
<span className={modalEyebrowClass}>STABLE VERSION</span> <div className="mx-auto mb-3.5 grid size-[58px] place-items-center rounded-full bg-gradient-to-br from-[#24b67d] to-[#149566] text-white shadow-[0_9px_24px_rgb(25_157_104/25%)]">
<h2 className="mb-2 mt-[5px] text-xl text-[#24374a]"> <Icon name="check" size={28} />
</div>
</h2> <span className={modalEyebrowClass}>STABLE VERSION</span>
<p className="mx-auto mb-5 max-w-[360px] text-[11px] leading-[1.65] text-[#718093]"> <h2 className="mb-2 mt-[5px] text-xl text-[#24374a]">
{publishedVersion.version_label}
versions_id </h2>
</p> <p className="mx-auto mb-5 max-w-[360px] text-[11px] leading-[1.65] text-[#718093]">
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-[9px] rounded-[7px] border border-[#dce6ee] bg-[#f7fafc] px-3 py-[11px] text-left"> {publishedVersion?.version_label}
<span className="text-[9px] text-[#8795a4]">versions_id</span> versions_id
<code className="truncate text-[10px] text-[#275271]"> </p>
{publishedVersion.versions_id} <div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-[9px] rounded-[7px] border border-[#dce6ee] bg-[#f7fafc] px-3 py-[11px] text-left">
</code> <span className="text-[9px] text-[#8795a4]">versions_id</span>
<button <code className="truncate text-[10px] text-[#275271]">
type="button" {publishedVersion?.versions_id}
className="cursor-pointer rounded border border-[#bad3e8] bg-white px-2 py-1 text-[9px] text-[#2475b7]" </code>
onClick={async () => {
await copyToClipboard(publishedVersion.versions_id);
onCopy("versions_id 已复制");
}}
>
</button>
</div>
<button <button
className={`${primaryButtonClass} mt-[17px] h-[39px] w-full border-0 bg-[#1976cf] shadow-none`}
type="button" type="button"
onClick={onClose} className="cursor-pointer rounded border border-[#bad3e8] bg-white px-2 py-1 text-[9px] text-[#2475b7]"
onClick={async () => {
if (!publishedVersion) return;
await copyToClipboard(publishedVersion.versions_id);
onCopy("versions_id 已复制");
}}
> >
</button> </button>
</section> </div>
</div> <Button
className={`${dialogPrimaryButtonClass} mt-[17px] h-[39px] w-full border-0 bg-[#1976cf] shadow-none`}
type="button"
onClick={onClose}
>
</Button>
</AppFormDialog>
); );
} }
+1 -1
View File
@@ -20,7 +20,7 @@ export const modalTitleClass = "mt-1 text-[19px] text-[#1c2d42]";
export const modalFormClass = "flex-1 overflow-y-auto px-[22px] pt-[19px]"; export const modalFormClass = "flex-1 overflow-y-auto px-[22px] pt-[19px]";
export const modalFooterClass = export const modalFooterClass =
"mt-[3px] flex shrink-0 justify-end gap-2 border-t border-[#e8edf2] bg-[#fafbfc] rounded-b-[11px] px-[22px] py-3.5"; "mt-[3px] flex shrink-0 justify-end gap-2 border-t border-[#e8edf2] bg-white rounded-b-[11px] px-[22px] py-3.5";
export const iconButtonClass = export const iconButtonClass =
"grid size-[34px] cursor-pointer place-items-center rounded-[7px] border border-[#dfe6ed] bg-white hover:border-[#b9c9da] hover:bg-[#f7faff]"; "grid size-[34px] cursor-pointer place-items-center rounded-[7px] border border-[#dfe6ed] bg-white hover:border-[#b9c9da] hover:bg-[#f7faff]";
@@ -31,14 +31,13 @@ import { useApi, useAuth } from "~/context/AuthContext";
import { import {
formFieldClass, formFieldClass,
formInputClass, formInputClass,
modalBackdropClass,
modalCompactClass,
modalEyebrowClass,
modalFooterClass,
modalFormClass, modalFormClass,
modalHeaderClass,
modalTitleClass,
} from "~/features/platform/modalUi"; } from "~/features/platform/modalUi";
import {
AppFormDialog,
dialogPrimaryButtonClass,
dialogSecondaryButtonClass,
} from "~/components/common/AppFormDialog";
import { NodeInspector } from "./NodeInspector"; import { NodeInspector } from "./NodeInspector";
import { RunHistory } from "./RunHistory"; import { RunHistory } from "./RunHistory";
import { ScheduleInspector } from "./ScheduleInspector"; import { ScheduleInspector } from "./ScheduleInspector";
@@ -762,34 +761,16 @@ export default function SchedulePage({
</div> </div>
)} )}
{createDialogOpen && ( <AppFormDialog
<div className={modalBackdropClass} role="presentation"> open={createDialogOpen}
<section onOpenChange={(nextOpen) => {
className={modalCompactClass} if (!nextOpen && !busy) setCreateDialogOpen(false);
role="dialog" }}
aria-modal="true" eyebrow="SCHEDULE"
aria-labelledby="create-schedule-title" title="新建调度方案"
> titleId="create-schedule-title"
<div className={modalHeaderClass}> >
<div> <form className={modalFormClass} onSubmit={handleCreateScheduleSubmit}>
<span className={modalEyebrowClass}>SCHEDULE</span>
<h2 id="create-schedule-title" className={modalTitleClass}>
</h2>
</div>
<Button
className="size-[34px] rounded-[7px] border-[#dfe6ed] bg-white hover:border-[#b9c9da] hover:bg-[#f7faff]"
type="button"
variant="ghost"
size="icon-lg"
aria-label="关闭"
disabled={Boolean(busy)}
onClick={() => setCreateDialogOpen(false)}
>
<X size={14} />
</Button>
</div>
<form className={modalFormClass} onSubmit={handleCreateScheduleSubmit}>
<label className={formFieldClass}> <label className={formFieldClass}>
<span></span> <span></span>
<input <input
@@ -802,19 +783,23 @@ export default function SchedulePage({
onFocus={(event) => event.currentTarget.select()} onFocus={(event) => event.currentTarget.select()}
/> />
</label> </label>
<div className={modalFooterClass}> <div className="mt-[3px] flex shrink-0 justify-end gap-2 border-t border-[#e8edf2] bg-white -mx-[22px] px-[22px] py-3.5">
<Button <Button
type="button" type="button"
variant="secondary" variant="outline"
size="sm"
disabled={Boolean(busy)} disabled={Boolean(busy)}
onClick={() => setCreateDialogOpen(false)} onClick={() => setCreateDialogOpen(false)}
className={dialogSecondaryButtonClass}
> >
</Button> </Button>
<Button <Button
type="submit" type="submit"
variant="default" variant="default"
size="sm"
disabled={Boolean(busy) || !newScheduleName.trim()} disabled={Boolean(busy) || !newScheduleName.trim()}
className={dialogPrimaryButtonClass}
> >
{busy === "create-schedule" {busy === "create-schedule"
? <span className="button-spinner" /> ? <span className="button-spinner" />
@@ -823,9 +808,7 @@ export default function SchedulePage({
</Button> </Button>
</div> </div>
</form> </form>
</section> </AppFormDialog>
</div>
)}
{busy && ( {busy && (
<div <div