feat(id-tags): add ParentTagAutocomplete component and enhance TagFormBody with random ID generation
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
DatePicker,
|
||||
EmptyState,
|
||||
Input,
|
||||
InputGroup,
|
||||
Label,
|
||||
ListBox,
|
||||
Modal,
|
||||
@@ -77,7 +78,7 @@ function UserAutocomplete({
|
||||
return (
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
placeholder="搜索用户…"
|
||||
placeholder="搜索用户"
|
||||
selectionMode="single"
|
||||
value={userId || null}
|
||||
onChange={(key) => onChange(key ? String(key) : "")}
|
||||
@@ -99,7 +100,7 @@ function UserAutocomplete({
|
||||
<SearchField autoFocus name="userSearch" variant="secondary">
|
||||
<SearchField.Group>
|
||||
<SearchField.SearchIcon />
|
||||
<SearchField.Input placeholder="搜索姓名或邮箱…" />
|
||||
<SearchField.Input placeholder="搜索姓名或邮箱" />
|
||||
<SearchField.ClearButton />
|
||||
</SearchField.Group>
|
||||
</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({
|
||||
form,
|
||||
setForm,
|
||||
isEdit,
|
||||
users,
|
||||
tags,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm: (f: FormState) => void;
|
||||
isEdit: boolean;
|
||||
users: UserRow[];
|
||||
tags: IdTag[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
disabled={isEdit}
|
||||
className="font-mono"
|
||||
@@ -168,7 +246,7 @@ function TagFormBody({
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">{isEdit ? "有效期" : "有效期 (可选)"}</Label>
|
||||
<Label className="text-sm font-medium">有效期</Label>
|
||||
<DatePicker
|
||||
value={form.expiryDate ? parseDate(form.expiryDate) : null}
|
||||
onChange={(date) => setForm({ ...form, expiryDate: date ? date.toString() : "" })}
|
||||
@@ -213,28 +291,32 @@ function TagFormBody({
|
||||
</DatePicker.Popover>
|
||||
</DatePicker>
|
||||
</div>
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">{isEdit ? "父卡号" : "父卡号 (可选)"}</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="parentIdTag"
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">父卡号</Label>
|
||||
<ParentTagAutocomplete
|
||||
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>
|
||||
<Label className="text-sm font-medium">余额(元)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={form.balance}
|
||||
onChange={(e) => setForm({ ...form, balance: e.target.value })}
|
||||
/>
|
||||
<Label className="text-sm font-medium">余额</Label>
|
||||
<InputGroup>
|
||||
<InputGroup.Prefix>¥</InputGroup.Prefix>
|
||||
<InputGroup.Input
|
||||
placeholder="0.00"
|
||||
min="0"
|
||||
step="0.01"
|
||||
type="number"
|
||||
value={form.balance}
|
||||
onChange={(e) => setForm({ ...form, balance: e.target.value })}
|
||||
/>
|
||||
<InputGroup.Suffix>CNY</InputGroup.Suffix>
|
||||
</InputGroup>
|
||||
</TextField>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">关联用户(可选)</Label>
|
||||
<Label className="text-sm font-medium">关联用户</Label>
|
||||
<UserAutocomplete
|
||||
userId={form.userId}
|
||||
onChange={(id) => setForm({ ...form, userId: id })}
|
||||
@@ -352,28 +434,34 @@ export default function IdTagsPage() {
|
||||
<Plus className="size-4" />
|
||||
新增储值卡
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-105">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>新增储值卡</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-3">
|
||||
<TagFormBody form={form} setForm={setForm} isEdit={false} users={users} />
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button isDisabled={saving} slot="close" onPress={handleSave}>
|
||||
{saving ? <Spinner size="sm" /> : "创建"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-105">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>新增储值卡</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-3">
|
||||
<TagFormBody
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
isEdit={false}
|
||||
users={users}
|
||||
tags={tags}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button isDisabled={saving} slot="close" onPress={handleSave}>
|
||||
{saving ? <Spinner size="sm" /> : "创建"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" isDisabled={claiming} onPress={handleClaim}>
|
||||
<Plus className="size-4" />
|
||||
@@ -418,15 +506,15 @@ export default function IdTagsPage() {
|
||||
</Table.Cell>
|
||||
<Table.Cell className="font-mono">¥{fenToYuan(tag.balance)}</Table.Cell>
|
||||
{isAdmin && (
|
||||
<Table.Cell className="text-sm">
|
||||
{owner ? (
|
||||
<span title={owner.email}>
|
||||
{owner.name ?? owner.username ?? owner.email}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="text-sm">
|
||||
{owner ? (
|
||||
<span title={owner.email}>
|
||||
{owner.name ?? owner.username ?? owner.email}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
)}
|
||||
<Table.Cell>
|
||||
{tag.expiryDate ? (
|
||||
@@ -442,94 +530,99 @@ export default function IdTagsPage() {
|
||||
{new Date(tag.createdAt).toLocaleString("zh-CN")}
|
||||
</Table.Cell>
|
||||
{isAdmin && (
|
||||
<Table.Cell>
|
||||
<div className="flex justify-end gap-1">
|
||||
{/* Edit button */}
|
||||
<Modal>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onPress={() => openEdit(tag)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-105">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>编辑储值卡</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-3">
|
||||
<TagFormBody
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
isEdit={true}
|
||||
users={users}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button isDisabled={saving} slot="close" onPress={handleSave}>
|
||||
{saving ? <Spinner size="sm" /> : "保存"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
{/* Delete button */}
|
||||
<Modal>
|
||||
<Button
|
||||
isDisabled={deletingTag === tag.idTag}
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="danger-soft"
|
||||
>
|
||||
{deletingTag === tag.idTag ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<TrashBin className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-96">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>确认删除储值卡</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="text-sm text-muted">
|
||||
将永久删除储值卡{" "}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{tag.idTag}
|
||||
</span>
|
||||
,此操作不可恢复。
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
slot="close"
|
||||
variant="danger"
|
||||
isDisabled={deletingTag === tag.idTag}
|
||||
onPress={() => handleDelete(tag.idTag)}
|
||||
>
|
||||
{deletingTag === tag.idTag ? <Spinner size="sm" /> : "确认删除"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div className="flex justify-end gap-1">
|
||||
{/* Edit button */}
|
||||
<Modal>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onPress={() => openEdit(tag)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-105">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>编辑储值卡</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-3">
|
||||
<TagFormBody
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
isEdit={true}
|
||||
users={users}
|
||||
tags={tags}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button isDisabled={saving} slot="close" onPress={handleSave}>
|
||||
{saving ? <Spinner size="sm" /> : "保存"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
{/* Delete button */}
|
||||
<Modal>
|
||||
<Button
|
||||
isDisabled={deletingTag === tag.idTag}
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="danger-soft"
|
||||
>
|
||||
{deletingTag === tag.idTag ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<TrashBin className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-96">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>确认删除储值卡</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="text-sm text-muted">
|
||||
将永久删除储值卡{" "}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{tag.idTag}
|
||||
</span>
|
||||
,此操作不可恢复。
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
slot="close"
|
||||
variant="danger"
|
||||
isDisabled={deletingTag === tag.idTag}
|
||||
onPress={() => handleDelete(tag.idTag)}
|
||||
>
|
||||
{deletingTag === tag.idTag ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
"确认删除"
|
||||
)}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
)}
|
||||
</Table.Row>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user