Edit: Add from Android phone contacts

This commit is contained in:
2026-09-21 20:51:06 +03:30
parent 9249302eac
commit be97a067f9
3 changed files with 110 additions and 41 deletions
+50 -31
View File
@@ -28,7 +28,7 @@ const emit = defineEmits<{
const pickerSupported = isContactPickerSupported(); const pickerSupported = isContactPickerSupported();
/** یک مودال با دو مرحله تا بعد از انتخاب فایل، لیست تأیید گم نشود */ /** یک مودال با دو مرحله تا بعد از انتخاب فایل، لیست تأیید گم نشود */
const phase = ref<'closed' | 'help' | 'review'>('closed'); const phase = ref<'closed' | 'choose' | 'help' | 'review'>('closed');
const importing = ref(false); const importing = ref(false);
const drafts = ref<PhoneContactDraft[]>([]); const drafts = ref<PhoneContactDraft[]>([]);
const selected = ref<Set<string>>(new Set()); const selected = ref<Set<string>>(new Set());
@@ -36,23 +36,17 @@ const listQuery = ref('');
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const modalOpen = computed(() => phase.value !== 'closed'); const modalOpen = computed(() => phase.value !== 'closed');
const modalTitle = computed(() => const modalTitle = computed(() => {
phase.value === 'review' ? 'انتخاب مخاطبین برای ورود' : 'ورود از مخاطبین گوشی', if (phase.value === 'review') return 'انتخاب مخاطبین برای ورود';
); if (phase.value === 'choose') return 'ورود از مخاطبین گوشی';
return 'ورود از فایل مخاطبین';
});
const visibleDrafts = computed(() => const visibleDrafts = computed(() =>
filterPhoneContacts(drafts.value, listQuery.value), filterPhoneContacts(drafts.value, listQuery.value),
); );
const selectedCount = computed( const selectedCount = computed(
() => drafts.value.filter((d) => selected.value.has(d.key)).length, () => drafts.value.filter((d) => selected.value.has(d.key)).length,
); );
const allSelected = computed(
() => drafts.value.length > 0 && selectedCount.value === drafts.value.length,
);
const visibleAllSelected = computed(
() =>
visibleDrafts.value.length > 0 &&
visibleDrafts.value.every((d) => selected.value.has(d.key)),
);
function apiMessage(e: unknown, fallback: string) { function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
@@ -111,18 +105,19 @@ function openHelp() {
phase.value = 'help'; phase.value = 'help';
} }
async function startPicker() { function openChoose() {
phase.value = 'choose';
}
async function runDevicePicker() {
try { try {
if (!pickerSupported) {
openHelp();
return;
}
const rows = await pickPhoneContacts({ multiple: true }); const rows = await pickPhoneContacts({ multiple: true });
if (!rows.length) { if (!rows.length) {
emit('fail', 'مخاطبی انتخاب نشد.'); emit('fail', 'مخاطبی انتخاب نشد.');
return; return;
} }
await showReview(rows, { selectAll: true }); // مثل vcf: جستجو/انتخاب دستی، نه ورود خودکار همه
await showReview(rows, { selectAll: false });
} catch (e) { } catch (e) {
const name = e instanceof DOMException ? e.name : ''; const name = e instanceof DOMException ? e.name : '';
const message = e instanceof Error ? e.message : ''; const message = e instanceof Error ? e.message : '';
@@ -135,6 +130,14 @@ async function startPicker() {
} }
} }
function startPicker() {
if (pickerSupported) {
openChoose();
return;
}
openHelp();
}
async function onVcfFile(ev: Event) { async function onVcfFile(ev: Event) {
const el = ev.target as HTMLInputElement; const el = ev.target as HTMLInputElement;
const file = el.files?.[0]; const file = el.files?.[0];
@@ -144,13 +147,13 @@ async function onVcfFile(ev: Event) {
const rows = await parseVCardFile(file); const rows = await parseVCardFile(file);
if (!rows.length) { if (!rows.length) {
emit('fail', 'مخاطبی برای ورود پیدا نشد.'); emit('fail', 'مخاطبی برای ورود پیدا نشد.');
phase.value = 'help'; phase.value = pickerSupported ? 'choose' : 'help';
return; return;
} }
await showReview(rows, { selectAll: false }); await showReview(rows, { selectAll: false });
} catch { } catch {
emit('fail', 'خواندن فایل مخاطبین (.vcf) ناموفق بود.'); emit('fail', 'خواندن فایل مخاطبین (.vcf) ناموفق بود.');
phase.value = 'help'; phase.value = pickerSupported ? 'choose' : 'help';
} }
} }
@@ -213,7 +216,23 @@ async function importSelected() {
/> />
<UiModal :open="modalOpen" :title="modalTitle" @close="cancel"> <UiModal :open="modalOpen" :title="modalTitle" @close="cancel">
<template v-if="phase === 'help'"> <template v-if="phase === 'choose'">
<p class="lead">
مخاطبین را از دفترچه تلفن یا فایل <strong>.vcf</strong> بگیرید.
بعد در لیست الفبایی جستجو کنید و یک، چند، یا همه را برای ورود انتخاب کنید.
</p>
<div class="choose-actions">
<UiButton variant="primary" block @click="runDevicePicker">
<UiIcon name="phone" :size="16" />
از دفترچه تلفن
</UiButton>
<UiButton variant="ghost" block @click="fileInput?.click()">
از فایل .vcf
</UiButton>
</div>
</template>
<template v-else-if="phase === 'help'">
<p class="lead"> <p class="lead">
در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمی‌شود. در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمی‌شود.
از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید. از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید.
@@ -275,7 +294,10 @@ async function importSelected() {
</template> </template>
<template #footer> <template #footer>
<template v-if="phase === 'help'"> <template v-if="phase === 'choose'">
<UiButton variant="ghost" @click="cancel">انصراف</UiButton>
</template>
<template v-else-if="phase === 'help'">
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton> <UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
<UiButton variant="primary" :disabled="importing" @click="fileInput?.click()"> <UiButton variant="primary" :disabled="importing" @click="fileInput?.click()">
انتخاب فایل .vcf انتخاب فایل .vcf
@@ -293,15 +315,7 @@ async function importSelected() {
@click="importSelected" @click="importSelected"
> >
<UiIcon name="user-plus" :size="16" /> <UiIcon name="user-plus" :size="16" />
<template v-if="allSelected && selectedCount && !listQuery.trim()"> ورود {{ toFaDigits(selectedCount) }} نفر
ورود همه ({{ toFaDigits(selectedCount) }})
</template>
<template v-else-if="visibleAllSelected && listQuery.trim() && selectedCount">
ورود {{ toFaDigits(selectedCount) }} نفر
</template>
<template v-else>
ورود {{ toFaDigits(selectedCount) }} نفر
</template>
</UiButton> </UiButton>
</template> </template>
</template> </template>
@@ -329,6 +343,11 @@ async function importSelected() {
line-height: 1.7; line-height: 1.7;
} }
.choose-actions {
display: grid;
gap: 0.55rem;
}
.tips { .tips {
margin: 0; margin: 0;
padding: 0 1.1rem 0 0; padding: 0 1.1rem 0 0;
+7 -1
View File
@@ -5,6 +5,8 @@ import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{ const props = defineProps<{
open: boolean; open: boolean;
title: string; title: string;
/** برای مودال روی مودال (مثل انتخاب مخاطب داخل فرم) */
nest?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -29,7 +31,7 @@ function onBackdrop(event: MouseEvent) {
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div v-if="open" class="backdrop" @click="onBackdrop"> <div v-if="open" class="backdrop" :class="{ nest }" @click="onBackdrop">
<section <section
class="dialog" class="dialog"
role="dialog" role="dialog"
@@ -65,6 +67,10 @@ function onBackdrop(event: MouseEvent) {
background: rgba(15, 23, 42, 0.35); background: rgba(15, 23, 42, 0.35);
} }
.backdrop.nest {
z-index: 100;
}
.dialog { .dialog {
width: min(880px, 100%); width: min(880px, 100%);
max-height: min(88dvh, 88vh, 880px); max-height: min(88dvh, 88vh, 880px);
+53 -9
View File
@@ -10,7 +10,7 @@ import {
filterPhoneContacts, filterPhoneContacts,
isContactPickerSupported, isContactPickerSupported,
parseVCardFile, parseVCardFile,
pickOnePhoneContact, pickPhoneContacts,
sortPhoneContacts, sortPhoneContacts,
type PhoneContactDraft, type PhoneContactDraft,
} from '@/lib/phone-contacts'; } from '@/lib/phone-contacts';
@@ -69,6 +69,7 @@ const loyaltyTierName = ref('');
const contactPickerSupported = isContactPickerSupported(); const contactPickerSupported = isContactPickerSupported();
const pickingContact = ref(false); const pickingContact = ref(false);
const contactFileInput = ref<HTMLInputElement | null>(null); const contactFileInput = ref<HTMLInputElement | null>(null);
const sourceOpen = ref(false);
const pickOpen = ref(false); const pickOpen = ref(false);
const pickDrafts = ref<PhoneContactDraft[]>([]); const pickDrafts = ref<PhoneContactDraft[]>([]);
const pickQuery = ref(''); const pickQuery = ref('');
@@ -112,6 +113,10 @@ function closePick() {
pickQuery.value = ''; pickQuery.value = '';
} }
function closeSource() {
sourceOpen.value = false;
}
function choosePick(row: PhoneContactDraft) { function choosePick(row: PhoneContactDraft) {
applyContactDraft(row); applyContactDraft(row);
closePick(); closePick();
@@ -120,23 +125,34 @@ function choosePick(row: PhoneContactDraft) {
async function openPickList(rows: PhoneContactDraft[]) { async function openPickList(rows: PhoneContactDraft[]) {
pickDrafts.value = sortPhoneContacts(rows); pickDrafts.value = sortPhoneContacts(rows);
pickQuery.value = ''; pickQuery.value = '';
sourceOpen.value = false;
pickOpen.value = false; pickOpen.value = false;
await nextTick(); await nextTick();
pickOpen.value = true; pickOpen.value = true;
} }
async function fillFromPhoneContact() { function fillFromPhoneContact() {
if (pickingContact.value || saving.value) return;
error.value = '';
if (contactPickerSupported) {
sourceOpen.value = true;
return;
}
contactFileInput.value?.click();
}
async function pickFromDevice() {
if (pickingContact.value || saving.value) return; if (pickingContact.value || saving.value) return;
pickingContact.value = true; pickingContact.value = true;
error.value = ''; error.value = '';
sourceOpen.value = false;
try { try {
if (!contactPickerSupported) { const rows = await pickPhoneContacts({ multiple: true });
contactFileInput.value?.click(); if (!rows.length) {
error.value = 'مخاطبی انتخاب نشد.';
return; return;
} }
const draft = await pickOnePhoneContact(); await openPickList(rows);
if (!draft) return;
applyContactDraft(draft);
} catch (e) { } catch (e) {
const name = e instanceof DOMException ? e.name : ''; const name = e instanceof DOMException ? e.name : '';
const message = e instanceof Error ? e.message : ''; const message = e instanceof Error ? e.message : '';
@@ -151,6 +167,11 @@ async function fillFromPhoneContact() {
} }
} }
function pickFromVcf() {
sourceOpen.value = false;
contactFileInput.value?.click();
}
async function onContactVcfFile(ev: Event) { async function onContactVcfFile(ev: Event) {
const el = ev.target as HTMLInputElement; const el = ev.target as HTMLInputElement;
const file = el.files?.[0]; const file = el.files?.[0];
@@ -339,9 +360,27 @@ onMounted(load);
@change="onContactVcfFile" @change="onContactVcfFile"
/> />
<UiModal :open="pickOpen" title="انتخاب مخاطب" @close="closePick"> <UiModal nest :open="sourceOpen" title="از مخاطبین" @close="closeSource">
<p class="pick-lead"> <p class="pick-lead">
{{ toFaDigits(pickDrafts.length) }} مخاطب در فایل (مرتب‌شده الفبایی). چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
</p>
<div class="pick-choose">
<UiButton variant="primary" block :disabled="pickingContact" @click="pickFromDevice">
<UiIcon name="phone" :size="16" />
از دفترچه تلفن
</UiButton>
<UiButton variant="ghost" block :disabled="pickingContact" @click="pickFromVcf">
از فایل .vcf
</UiButton>
</div>
<template #footer>
<UiButton variant="ghost" @click="closeSource">انصراف</UiButton>
</template>
</UiModal>
<UiModal nest :open="pickOpen" title="انتخاب مخاطب" @close="closePick">
<p class="pick-lead">
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتب‌شده الفبایی).
یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود. یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
</p> </p>
<div class="pick-search"> <div class="pick-search">
@@ -561,6 +600,11 @@ onMounted(load);
line-height: 1.7; line-height: 1.7;
} }
.pick-choose {
display: grid;
gap: 0.55rem;
}
.pick-search { .pick-search {
display: flex; display: flex;
align-items: center; align-items: center;