feat(id-tags): add ParentTagAutocomplete component and enhance TagFormBody with random ID generation
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user