feat(id-tags): add ParentTagAutocomplete component and enhance TagFormBody with random ID generation

This commit is contained in:
2026-03-10 21:51:41 +08:00
parent 4f9fbe13fd
commit a349286049

View File

@@ -10,6 +10,7 @@ import {
DatePicker, DatePicker,
EmptyState, EmptyState,
Input, Input,
InputGroup,
Label, Label,
ListBox, ListBox,
Modal, Modal,
@@ -77,7 +78,7 @@ function UserAutocomplete({
return ( return (
<Autocomplete <Autocomplete
fullWidth fullWidth
placeholder="搜索用户" placeholder="搜索用户"
selectionMode="single" selectionMode="single"
value={userId || null} value={userId || null}
onChange={(key) => onChange(key ? String(key) : "")} onChange={(key) => onChange(key ? String(key) : "")}
@@ -99,7 +100,7 @@ function UserAutocomplete({
<SearchField autoFocus name="userSearch" variant="secondary"> <SearchField autoFocus name="userSearch" variant="secondary">
<SearchField.Group> <SearchField.Group>
<SearchField.SearchIcon /> <SearchField.SearchIcon />
<SearchField.Input placeholder="搜索姓名或邮箱" /> <SearchField.Input placeholder="搜索姓名或邮箱" />
<SearchField.ClearButton /> <SearchField.ClearButton />
</SearchField.Group> </SearchField.Group>
</SearchField> </SearchField>
@@ -122,21 +123,98 @@ function UserAutocomplete({
); );
} }
function ParentTagAutocomplete({
value,
onChange,
tags,
excludeTag,
}: {
value: string;
onChange: (v: string) => void;
tags: IdTag[];
excludeTag?: string;
}) {
const { contains } = useFilter({ sensitivity: "base" });
const options = tags.filter((t) => t.idTag !== excludeTag);
return (
<Autocomplete
fullWidth
placeholder="搜索卡号"
selectionMode="single"
value={value || null}
onChange={(key) => onChange(key ? String(key) : "")}
>
<Autocomplete.Trigger>
<Autocomplete.Value>
{({ isPlaceholder, state }: any) => {
if (isPlaceholder || !state.selectedItems?.length)
return <span className="text-muted"></span>;
return <span className="font-mono">{state.selectedItems[0]?.key}</span>;
}}
</Autocomplete.Value>
<Autocomplete.ClearButton />
<Autocomplete.Indicator />
</Autocomplete.Trigger>
<Autocomplete.Popover>
<Autocomplete.Filter filter={contains}>
<SearchField autoFocus name="parentTagSearch" variant="secondary">
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input placeholder="搜索卡号" />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
<ListBox renderEmptyState={() => <EmptyState></EmptyState>}>
{options.map((t) => (
<ListBox.Item key={t.idTag} id={t.idTag} textValue={t.idTag}>
<span className="font-mono">{t.idTag}</span>
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
}
function generateIdTag(): string {
const chars = "0123456789ABCDEF";
let result = "";
for (let i = 0; i < 8; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
function TagFormBody({ function TagFormBody({
form, form,
setForm, setForm,
isEdit, isEdit,
users, users,
tags,
}: { }: {
form: FormState; form: FormState;
setForm: (f: FormState) => void; setForm: (f: FormState) => void;
isEdit: boolean; isEdit: boolean;
users: UserRow[]; users: UserRow[];
tags: IdTag[];
}) { }) {
return ( return (
<> <>
<TextField fullWidth> <TextField fullWidth>
<Label className="text-sm font-medium">{isEdit ? "卡号" : "卡号 (idTag)"}</Label> <div className="flex items-center justify-between">
<Label className="text-sm font-medium">{isEdit ? "卡号" : "卡号 (idTag)"}</Label>
{!isEdit && (
<button
type="button"
className="text-xs text-accent hover:underline"
onClick={() => setForm({ ...form, idTag: generateIdTag() })}
>
</button>
)}
</div>
<Input <Input
disabled={isEdit} disabled={isEdit}
className="font-mono" className="font-mono"
@@ -168,7 +246,7 @@ function TagFormBody({
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Label className="text-sm font-medium">{isEdit ? "有效期" : "有效期 (可选)"}</Label> <Label className="text-sm font-medium"></Label>
<DatePicker <DatePicker
value={form.expiryDate ? parseDate(form.expiryDate) : null} value={form.expiryDate ? parseDate(form.expiryDate) : null}
onChange={(date) => setForm({ ...form, expiryDate: date ? date.toString() : "" })} onChange={(date) => setForm({ ...form, expiryDate: date ? date.toString() : "" })}
@@ -213,28 +291,32 @@ function TagFormBody({
</DatePicker.Popover> </DatePicker.Popover>
</DatePicker> </DatePicker>
</div> </div>
<TextField fullWidth> <div className="flex flex-col gap-1">
<Label className="text-sm font-medium">{isEdit ? "父卡号" : "父卡号 (可选)"}</Label> <Label className="text-sm font-medium"></Label>
<Input <ParentTagAutocomplete
className="font-mono"
placeholder="parentIdTag"
value={form.parentIdTag} value={form.parentIdTag}
onChange={(e) => setForm({ ...form, parentIdTag: e.target.value })} onChange={(v) => setForm({ ...form, parentIdTag: v })}
tags={tags}
excludeTag={isEdit ? form.idTag : undefined}
/> />
</TextField> </div>
<TextField fullWidth> <TextField fullWidth>
<Label className="text-sm font-medium"></Label> <Label className="text-sm font-medium"></Label>
<Input <InputGroup>
type="number" <InputGroup.Prefix></InputGroup.Prefix>
min="0" <InputGroup.Input
step="0.01" placeholder="0.00"
placeholder="0.00" min="0"
value={form.balance} step="0.01"
onChange={(e) => setForm({ ...form, balance: e.target.value })} type="number"
/> value={form.balance}
onChange={(e) => setForm({ ...form, balance: e.target.value })}
/>
<InputGroup.Suffix>CNY</InputGroup.Suffix>
</InputGroup>
</TextField> </TextField>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Label className="text-sm font-medium"></Label> <Label className="text-sm font-medium"></Label>
<UserAutocomplete <UserAutocomplete
userId={form.userId} userId={form.userId}
onChange={(id) => setForm({ ...form, userId: id })} onChange={(id) => setForm({ ...form, userId: id })}
@@ -352,28 +434,34 @@ export default function IdTagsPage() {
<Plus className="size-4" /> <Plus className="size-4" />
</Button> </Button>
<Modal.Backdrop> <Modal.Backdrop>
<Modal.Container scroll="outside"> <Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-105"> <Modal.Dialog className="sm:max-w-105">
<Modal.CloseTrigger /> <Modal.CloseTrigger />
<Modal.Header> <Modal.Header>
<Modal.Heading></Modal.Heading> <Modal.Heading></Modal.Heading>
</Modal.Header> </Modal.Header>
<Modal.Body className="space-y-3"> <Modal.Body className="space-y-3">
<TagFormBody form={form} setForm={setForm} isEdit={false} users={users} /> <TagFormBody
</Modal.Body> form={form}
<Modal.Footer className="flex justify-end gap-2"> setForm={setForm}
<Button slot="close" variant="ghost"> isEdit={false}
users={users}
</Button> tags={tags}
<Button isDisabled={saving} slot="close" onPress={handleSave}> />
{saving ? <Spinner size="sm" /> : "创建"} </Modal.Body>
</Button> <Modal.Footer className="flex justify-end gap-2">
</Modal.Footer> <Button slot="close" variant="ghost">
</Modal.Dialog>
</Modal.Container> </Button>
</Modal.Backdrop> <Button isDisabled={saving} slot="close" onPress={handleSave}>
</Modal> {saving ? <Spinner size="sm" /> : "创建"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
) : ( ) : (
<Button size="sm" variant="secondary" isDisabled={claiming} onPress={handleClaim}> <Button size="sm" variant="secondary" isDisabled={claiming} onPress={handleClaim}>
<Plus className="size-4" /> <Plus className="size-4" />
@@ -418,15 +506,15 @@ export default function IdTagsPage() {
</Table.Cell> </Table.Cell>
<Table.Cell className="font-mono">¥{fenToYuan(tag.balance)}</Table.Cell> <Table.Cell className="font-mono">¥{fenToYuan(tag.balance)}</Table.Cell>
{isAdmin && ( {isAdmin && (
<Table.Cell className="text-sm"> <Table.Cell className="text-sm">
{owner ? ( {owner ? (
<span title={owner.email}> <span title={owner.email}>
{owner.name ?? owner.username ?? owner.email} {owner.name ?? owner.username ?? owner.email}
</span> </span>
) : ( ) : (
<span className="text-muted"></span> <span className="text-muted"></span>
)} )}
</Table.Cell> </Table.Cell>
)} )}
<Table.Cell> <Table.Cell>
{tag.expiryDate ? ( {tag.expiryDate ? (
@@ -442,94 +530,99 @@ export default function IdTagsPage() {
{new Date(tag.createdAt).toLocaleString("zh-CN")} {new Date(tag.createdAt).toLocaleString("zh-CN")}
</Table.Cell> </Table.Cell>
{isAdmin && ( {isAdmin && (
<Table.Cell> <Table.Cell>
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
{/* Edit button */} {/* Edit button */}
<Modal> <Modal>
<Button <Button
isIconOnly isIconOnly
size="sm" size="sm"
variant="tertiary" variant="tertiary"
onPress={() => openEdit(tag)} onPress={() => openEdit(tag)}
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</Button> </Button>
<Modal.Backdrop> <Modal.Backdrop>
<Modal.Container scroll="outside"> <Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-105"> <Modal.Dialog className="sm:max-w-105">
<Modal.CloseTrigger /> <Modal.CloseTrigger />
<Modal.Header> <Modal.Header>
<Modal.Heading></Modal.Heading> <Modal.Heading></Modal.Heading>
</Modal.Header> </Modal.Header>
<Modal.Body className="space-y-3"> <Modal.Body className="space-y-3">
<TagFormBody <TagFormBody
form={form} form={form}
setForm={setForm} setForm={setForm}
isEdit={true} isEdit={true}
users={users} users={users}
/> tags={tags}
</Modal.Body> />
<Modal.Footer className="flex justify-end gap-2"> </Modal.Body>
<Button slot="close" variant="ghost"> <Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button isDisabled={saving} slot="close" onPress={handleSave}> </Button>
{saving ? <Spinner size="sm" /> : "保存"} <Button isDisabled={saving} slot="close" onPress={handleSave}>
</Button> {saving ? <Spinner size="sm" /> : "保存"}
</Modal.Footer> </Button>
</Modal.Dialog> </Modal.Footer>
</Modal.Container> </Modal.Dialog>
</Modal.Backdrop> </Modal.Container>
</Modal> </Modal.Backdrop>
{/* Delete button */} </Modal>
<Modal> {/* Delete button */}
<Button <Modal>
isDisabled={deletingTag === tag.idTag} <Button
isIconOnly isDisabled={deletingTag === tag.idTag}
size="sm" isIconOnly
variant="danger-soft" size="sm"
> variant="danger-soft"
{deletingTag === tag.idTag ? ( >
<Spinner size="sm" /> {deletingTag === tag.idTag ? (
) : ( <Spinner size="sm" />
<TrashBin className="size-4" /> ) : (
)} <TrashBin className="size-4" />
</Button> )}
<Modal.Backdrop> </Button>
<Modal.Container scroll="outside"> <Modal.Backdrop>
<Modal.Dialog className="sm:max-w-96"> <Modal.Container scroll="outside">
<Modal.CloseTrigger /> <Modal.Dialog className="sm:max-w-96">
<Modal.Header> <Modal.CloseTrigger />
<Modal.Heading></Modal.Heading> <Modal.Header>
</Modal.Header> <Modal.Heading></Modal.Heading>
<Modal.Body> </Modal.Header>
<p className="text-sm text-muted"> <Modal.Body>
{" "} <p className="text-sm text-muted">
<span className="font-mono font-medium text-foreground"> {" "}
{tag.idTag} <span className="font-mono font-medium text-foreground">
</span> {tag.idTag}
</span>
</p>
</Modal.Body> </p>
<Modal.Footer className="flex justify-end gap-2"> </Modal.Body>
<Button slot="close" variant="ghost"> <Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button </Button>
slot="close" <Button
variant="danger" slot="close"
isDisabled={deletingTag === tag.idTag} variant="danger"
onPress={() => handleDelete(tag.idTag)} isDisabled={deletingTag === tag.idTag}
> onPress={() => handleDelete(tag.idTag)}
{deletingTag === tag.idTag ? <Spinner size="sm" /> : "确认删除"} >
</Button> {deletingTag === tag.idTag ? (
</Modal.Footer> <Spinner size="sm" />
</Modal.Dialog> ) : (
</Modal.Container> "确认删除"
</Modal.Backdrop> )}
</Modal> </Button>
</div> </Modal.Footer>
</Table.Cell> </Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
</Table.Cell>
)} )}
</Table.Row> </Table.Row>
); );