added table of co-woeker requests and new features 3
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<a-form :model="form" ref="formRef" autocomplete="off">
|
||||
<div class="space-y-3 md:grid md:grid-cols-4 md:gap-2 md:space-y-0">
|
||||
<a-form-item label="انتخاب گروه" class="md:col-span-4">
|
||||
<div class="space-y-3">
|
||||
<a-form-item label="انتخاب گروه" class="!mb-0">
|
||||
<BasicSelect
|
||||
v-model:value="selectedGroup"
|
||||
:items="groupItems"
|
||||
@@ -12,63 +12,88 @@
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="انتخاب کاربر" class="md:col-span-4 mb-0">
|
||||
<BasicInput
|
||||
place-holder="جستجو"
|
||||
class="mb-2"
|
||||
@onchange="filterNumbers"
|
||||
:disabled="selectedGroup.length === 0"
|
||||
/>
|
||||
<div class="flex flex-col gap-3 md:grid md:grid-cols-3 md:items-stretch md:gap-3">
|
||||
<a-form-item label="انتخاب کاربر" class="!mb-0 md:col-span-2">
|
||||
<BasicInput
|
||||
place-holder="جستجو نام یا شماره"
|
||||
class="mb-2"
|
||||
@onchange="filterNumbers"
|
||||
:disabled="selectedGroup.length === 0"
|
||||
/>
|
||||
|
||||
<a-table
|
||||
class="hidden md:block"
|
||||
rowKey="mobile"
|
||||
:row-selection="{ selectedRowKeys: form.numbers, onChange: onSelectChange }"
|
||||
:columns="columns"
|
||||
:scroll="{ y: '20vh', x: 500 }"
|
||||
:pagination="false"
|
||||
:data-source="groupNumbers"
|
||||
/>
|
||||
<a-table
|
||||
class="hidden md:block"
|
||||
rowKey="mobile"
|
||||
:row-selection="rowSelection"
|
||||
:columns="columns"
|
||||
:scroll="{ y: 280, x: 500 }"
|
||||
:pagination="false"
|
||||
:data-source="groupNumbers"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="md:hidden max-h-48 overflow-y-auto rounded-xl border border-gray-200 bg-gray-50 p-2 space-y-1"
|
||||
>
|
||||
<div v-if="customersLoading" class="flex justify-center py-6">
|
||||
<a-spin size="small" />
|
||||
</div>
|
||||
<div v-else-if="!groupNumbers.length" class="py-6 text-center text-sm text-gray-500">
|
||||
کاربری یافت نشد
|
||||
</div>
|
||||
<label
|
||||
v-for="customer in groupNumbers"
|
||||
:key="customer.mobile"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg border border-transparent bg-white px-3 py-2 active:bg-gray-100"
|
||||
:class="isSelected(customer.mobile) ? 'border-blue-200 bg-blue-50' : ''"
|
||||
<div
|
||||
class="md:hidden max-h-48 overflow-y-auto rounded-xl border border-gray-200 bg-gray-50 p-2 space-y-1"
|
||||
>
|
||||
<a-checkbox
|
||||
:checked="isSelected(customer.mobile)"
|
||||
@change="(e) => toggleCustomer(customer.mobile, e.target.checked)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-600 text-gray-900">{{ customer.name || '-' }}</div>
|
||||
<div class="truncate text-xs text-gray-500">{{ customer.mobile }}</div>
|
||||
<div v-if="customersLoading" class="flex justify-center py-6">
|
||||
<a-spin size="small" />
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-gray-400">#{{ customer.id }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<div v-else-if="!groupNumbers.length" class="py-6 text-center text-sm text-gray-500">
|
||||
کاربری یافت نشد
|
||||
</div>
|
||||
<label
|
||||
v-for="customer in groupNumbers"
|
||||
:key="customer.mobile"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg border border-transparent bg-white px-3 py-2 active:bg-gray-100"
|
||||
:class="isSelected(customer.mobile) ? 'border-blue-200 bg-blue-50' : ''"
|
||||
>
|
||||
<a-checkbox
|
||||
:checked="isSelected(customer.mobile)"
|
||||
@change="(e) => toggleCustomer(customer.mobile, e.target.checked)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-600 text-gray-900">{{ customer.name || '-' }}</div>
|
||||
<div class="truncate text-xs text-gray-500">{{ customer.mobile }}</div>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-gray-400">#{{ customer.id }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="متن پیام" name="message" class="md:col-span-4 mb-0">
|
||||
<BasicTextArea v-model:value="form.message" />
|
||||
<div class="mt-2 space-y-2 md:flex md:items-center md:justify-between md:flex-row-reverse md:space-y-0">
|
||||
<BaseButton type="primary" class="w-full md:w-10rem" @click="addName">افزودن نام کاربر</BaseButton>
|
||||
<p class="text-xs leading-5 text-gray-500 md:max-w-[70%]">
|
||||
برای استفاده از نام اشخاص در پیام از گزینه افزودن نام کاربر استفاده کنید. این متغیر با
|
||||
عنوان (%name) به متن پیام اضافه میگردد.
|
||||
</p>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="متن پیام" name="message" class="!mb-0 md:col-span-1" :rules="messageRules">
|
||||
<a-textarea
|
||||
ref="messageAreaRef"
|
||||
v-model:value="form.message"
|
||||
class="sms-message-area"
|
||||
:rows="10"
|
||||
placeholder="متن پیام را بنویسید…"
|
||||
@blur="rememberCursor"
|
||||
@click="rememberCursor"
|
||||
@keyup="rememberCursor"
|
||||
@select="rememberCursor"
|
||||
/>
|
||||
<div
|
||||
class="mt-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600"
|
||||
>
|
||||
<span class="font-600 text-gray-800">پایان پیام (اجباری):</span>
|
||||
<span class="ms-1">{{ appDisplayName }}</span>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<BasicButton type="primary" class="!w-full" @mousedown.prevent="addName">
|
||||
افزودن نام کاربر
|
||||
</BasicButton>
|
||||
<p class="m-0 text-xs leading-5 text-gray-500">
|
||||
با زدن دکمه، در محل کرسر توکن
|
||||
<bdi class="font-700">%نام</bdi>
|
||||
اضافه میشود. معادلها:
|
||||
<bdi>%name</bdi>،
|
||||
<bdi>٪نام</bdi>،
|
||||
<bdi>% نام</bdi>
|
||||
هم پذیرفته میشوند.
|
||||
</p>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</a-form>
|
||||
</template>
|
||||
@@ -79,6 +104,10 @@ import type { FunctionItem } from '@/model/Item'
|
||||
import type { Customer } from '@/model/customer'
|
||||
import type { Sms } from '@/model/sms'
|
||||
import { getCustomerList } from '@/api/sms'
|
||||
import { getConfiguration } from '@/api/configuration'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const NAME_TOKEN = '%نام'
|
||||
|
||||
const props = defineProps<{
|
||||
form: Sms
|
||||
@@ -87,10 +116,14 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const messageAreaRef = ref<{ $el?: HTMLElement } | null>(null)
|
||||
const selectedGroup = ref<string[]>([])
|
||||
const groupNumbers = ref<Customer[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const appName = ref('')
|
||||
let baseGroupNumbers: Customer[] = []
|
||||
let cursorStart = 0
|
||||
let cursorEnd = 0
|
||||
|
||||
const columns = [
|
||||
{ title: 'کد کاربر', dataIndex: 'id' },
|
||||
@@ -98,16 +131,91 @@ const columns = [
|
||||
{ title: 'شماره همراه', dataIndex: 'mobile' }
|
||||
]
|
||||
|
||||
const appDisplayName = computed(() => appName.value.trim() || 'اپ')
|
||||
|
||||
const messageRules = [
|
||||
{
|
||||
required: true,
|
||||
validator: async (_: unknown, value: string) => {
|
||||
if (!String(value ?? '').trim()) {
|
||||
return Promise.reject('متن پیام خالی است')
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: props.form.numbers ?? [],
|
||||
onChange: onSelectChange,
|
||||
preserveSelectedRowKeys: true
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
void loadAppName()
|
||||
})
|
||||
|
||||
async function loadAppName() {
|
||||
try {
|
||||
const rows = await getConfiguration()
|
||||
const list = Array.isArray(rows) ? rows : []
|
||||
appName.value = String(list.find((r) => r.key === 'name_fa')?.value ?? '').trim()
|
||||
} catch {
|
||||
appName.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function getTextareaEl(): HTMLTextAreaElement | null {
|
||||
const root = messageAreaRef.value?.$el
|
||||
if (!root) return null
|
||||
if (root instanceof HTMLTextAreaElement) return root
|
||||
return root.querySelector?.('textarea') ?? null
|
||||
}
|
||||
|
||||
function rememberCursor() {
|
||||
const ta = getTextareaEl()
|
||||
if (!ta) return
|
||||
cursorStart = ta.selectionStart ?? 0
|
||||
cursorEnd = ta.selectionEnd ?? cursorStart
|
||||
}
|
||||
|
||||
function addName() {
|
||||
props.form.message = (props.form.message ?? '').concat('', '%name')
|
||||
rememberCursor()
|
||||
const text = props.form.message ?? ''
|
||||
const start = cursorStart
|
||||
const end = cursorEnd
|
||||
props.form.message = text.slice(0, start) + NAME_TOKEN + text.slice(end)
|
||||
const nextPos = start + NAME_TOKEN.length
|
||||
cursorStart = nextPos
|
||||
cursorEnd = nextPos
|
||||
nextTick(() => {
|
||||
const ta = getTextareaEl()
|
||||
if (!ta) return
|
||||
ta.focus()
|
||||
ta.setSelectionRange(nextPos, nextPos)
|
||||
})
|
||||
}
|
||||
|
||||
/** نام اپ را بهصورت خط آخر اجباری میچسباند */
|
||||
function buildFinalMessage(): string {
|
||||
const body = String(props.form.message ?? '').trim()
|
||||
const app = appDisplayName.value
|
||||
if (!body) return app
|
||||
const lines = body.split(/\r?\n/)
|
||||
const last = (lines[lines.length - 1] ?? '').trim()
|
||||
if (last === app) return body
|
||||
return `${body}\n${app}`
|
||||
}
|
||||
|
||||
function filterNumbers(val: string) {
|
||||
if (val !== '') {
|
||||
groupNumbers.value = baseGroupNumbers.filter((v) => v.name?.includes(val))
|
||||
} else {
|
||||
const q = String(val ?? '').trim()
|
||||
if (!q) {
|
||||
groupNumbers.value = baseGroupNumbers
|
||||
return
|
||||
}
|
||||
groupNumbers.value = baseGroupNumbers.filter(
|
||||
(v) => v.name?.includes(q) || String(v.mobile ?? '').includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
async function loadGroupNumbers(val: string[]) {
|
||||
@@ -123,7 +231,6 @@ async function loadGroupNumbers(val: string[]) {
|
||||
const { customers } = await getCustomerList(val)
|
||||
groupNumbers.value = customers
|
||||
baseGroupNumbers = customers
|
||||
// فقط شمارههای همین لیست مشتری بمانند
|
||||
const allowed = new Set(customers.map((c) => c.mobile).filter(Boolean) as string[])
|
||||
props.form.numbers = (props.form.numbers ?? []).filter((n) => allowed.has(n))
|
||||
} catch (e) {
|
||||
@@ -133,9 +240,22 @@ async function loadGroupNumbers(val: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectChange(changableRowKeys: string[]) {
|
||||
const allowed = new Set(baseGroupNumbers.map((c) => c.mobile).filter(Boolean) as string[])
|
||||
props.form.numbers = changableRowKeys.filter((n) => allowed.has(n))
|
||||
/**
|
||||
* تیکهای خارج از نتیجهٔ سرچ فعلی حفظ میشوند؛
|
||||
* فقط وضعیت ردیفهای قابلمشاهده از selectedRowKeys بهروز میشود.
|
||||
*/
|
||||
function onSelectChange(selectedRowKeys: string[]) {
|
||||
const visible = new Set(
|
||||
groupNumbers.value.map((c) => c.mobile).filter(Boolean) as string[]
|
||||
)
|
||||
const selectedVisible = new Set(selectedRowKeys)
|
||||
const next = new Set((props.form.numbers ?? []).filter((m) => !visible.has(m)))
|
||||
for (const mobile of selectedVisible) {
|
||||
if (baseGroupNumbers.some((c) => c.mobile === mobile)) {
|
||||
next.add(mobile)
|
||||
}
|
||||
}
|
||||
props.form.numbers = Array.from(next)
|
||||
}
|
||||
|
||||
function isSelected(mobile?: string) {
|
||||
@@ -162,10 +282,26 @@ function resetAll() {
|
||||
groupNumbers.value = []
|
||||
baseGroupNumbers = []
|
||||
props.form.numbers = []
|
||||
props.form.message = ''
|
||||
cursorStart = 0
|
||||
cursorEnd = 0
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
validateFields: () => formRef.value?.validateFields(),
|
||||
resetAll
|
||||
resetAll,
|
||||
buildFinalMessage
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sms-message-area {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.sms-message-area {
|
||||
min-height: 280px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,6 +53,7 @@ const table = ref<null | { fetch: () => void }>(null)
|
||||
const smsEditorRef = ref<{
|
||||
validateFields?: () => ReturnType<NonNullable<FormInstance['validateFields']>>
|
||||
resetAll?: () => void
|
||||
buildFinalMessage?: () => string
|
||||
}>()
|
||||
const sendStore = useGroupSmsSendStore()
|
||||
|
||||
@@ -138,7 +139,7 @@ function ok() {
|
||||
return
|
||||
}
|
||||
|
||||
const text = form.value.message
|
||||
const text = smsEditorRef.value?.buildFinalMessage?.() ?? form.value.message
|
||||
addSMSVisible.value = false
|
||||
resetData()
|
||||
await sendStore.start(numbers, text)
|
||||
|
||||
Reference in New Issue
Block a user