added table of co-woeker requests and new features 3

This commit is contained in:
2026-08-16 20:46:48 +03:30
parent f192d3e171
commit 7d38ed23e9
2 changed files with 201 additions and 64 deletions
@@ -1,7 +1,7 @@
<template> <template>
<a-form :model="form" ref="formRef" autocomplete="off"> <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"> <div class="space-y-3">
<a-form-item label="انتخاب گروه" class="md:col-span-4"> <a-form-item label="انتخاب گروه" class="!mb-0">
<BasicSelect <BasicSelect
v-model:value="selectedGroup" v-model:value="selectedGroup"
:items="groupItems" :items="groupItems"
@@ -12,63 +12,88 @@
/> />
</a-form-item> </a-form-item>
<a-form-item label="انتخاب کاربر" class="md:col-span-4 mb-0"> <div class="flex flex-col gap-3 md:grid md:grid-cols-3 md:items-stretch md:gap-3">
<BasicInput <a-form-item label="انتخاب کاربر" class="!mb-0 md:col-span-2">
place-holder="جستجو" <BasicInput
class="mb-2" place-holder="جستجو نام یا شماره"
@onchange="filterNumbers" class="mb-2"
:disabled="selectedGroup.length === 0" @onchange="filterNumbers"
/> :disabled="selectedGroup.length === 0"
/>
<a-table <a-table
class="hidden md:block" class="hidden md:block"
rowKey="mobile" rowKey="mobile"
:row-selection="{ selectedRowKeys: form.numbers, onChange: onSelectChange }" :row-selection="rowSelection"
:columns="columns" :columns="columns"
:scroll="{ y: '20vh', x: 500 }" :scroll="{ y: 280, x: 500 }"
:pagination="false" :pagination="false"
:data-source="groupNumbers" :data-source="groupNumbers"
/> />
<div <div
class="md:hidden max-h-48 overflow-y-auto rounded-xl border border-gray-200 bg-gray-50 p-2 space-y-1" 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' : ''"
> >
<a-checkbox <div v-if="customersLoading" class="flex justify-center py-6">
:checked="isSelected(customer.mobile)" <a-spin size="small" />
@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> </div>
<span class="shrink-0 text-xs text-gray-400">#{{ customer.id }}</span> <div v-else-if="!groupNumbers.length" class="py-6 text-center text-sm text-gray-500">
</label> کاربری یافت نشد
</div> </div>
</a-form-item> <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"> <a-form-item label="متن پیام" name="message" class="!mb-0 md:col-span-1" :rules="messageRules">
<BasicTextArea v-model:value="form.message" /> <a-textarea
<div class="mt-2 space-y-2 md:flex md:items-center md:justify-between md:flex-row-reverse md:space-y-0"> ref="messageAreaRef"
<BaseButton type="primary" class="w-full md:w-10rem" @click="addName">افزودن نام کاربر</BaseButton> v-model:value="form.message"
<p class="text-xs leading-5 text-gray-500 md:max-w-[70%]"> class="sms-message-area"
برای استفاده از نام اشخاص در پیام از گزینه افزودن نام کاربر استفاده کنید. این متغیر با :rows="10"
عنوان (%name) به متن پیام اضافه می‌گردد. placeholder="متن پیام را بنویسید…"
</p> @blur="rememberCursor"
</div> @click="rememberCursor"
</a-form-item> @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> </div>
</a-form> </a-form>
</template> </template>
@@ -79,6 +104,10 @@ import type { FunctionItem } from '@/model/Item'
import type { Customer } from '@/model/customer' import type { Customer } from '@/model/customer'
import type { Sms } from '@/model/sms' import type { Sms } from '@/model/sms'
import { getCustomerList } from '@/api/sms' import { getCustomerList } from '@/api/sms'
import { getConfiguration } from '@/api/configuration'
import { nextTick } from 'vue'
const NAME_TOKEN = '%نام'
const props = defineProps<{ const props = defineProps<{
form: Sms form: Sms
@@ -87,10 +116,14 @@ const props = defineProps<{
}>() }>()
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const messageAreaRef = ref<{ $el?: HTMLElement } | null>(null)
const selectedGroup = ref<string[]>([]) const selectedGroup = ref<string[]>([])
const groupNumbers = ref<Customer[]>([]) const groupNumbers = ref<Customer[]>([])
const customersLoading = ref(false) const customersLoading = ref(false)
const appName = ref('')
let baseGroupNumbers: Customer[] = [] let baseGroupNumbers: Customer[] = []
let cursorStart = 0
let cursorEnd = 0
const columns = [ const columns = [
{ title: 'کد کاربر', dataIndex: 'id' }, { title: 'کد کاربر', dataIndex: 'id' },
@@ -98,16 +131,91 @@ const columns = [
{ title: 'شماره همراه', dataIndex: 'mobile' } { 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() { 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) { function filterNumbers(val: string) {
if (val !== '') { const q = String(val ?? '').trim()
groupNumbers.value = baseGroupNumbers.filter((v) => v.name?.includes(val)) if (!q) {
} else {
groupNumbers.value = baseGroupNumbers groupNumbers.value = baseGroupNumbers
return
} }
groupNumbers.value = baseGroupNumbers.filter(
(v) => v.name?.includes(q) || String(v.mobile ?? '').includes(q)
)
} }
async function loadGroupNumbers(val: string[]) { async function loadGroupNumbers(val: string[]) {
@@ -123,7 +231,6 @@ async function loadGroupNumbers(val: string[]) {
const { customers } = await getCustomerList(val) const { customers } = await getCustomerList(val)
groupNumbers.value = customers groupNumbers.value = customers
baseGroupNumbers = customers baseGroupNumbers = customers
// فقط شماره‌های همین لیست مشتری بمانند
const allowed = new Set(customers.map((c) => c.mobile).filter(Boolean) as string[]) const allowed = new Set(customers.map((c) => c.mobile).filter(Boolean) as string[])
props.form.numbers = (props.form.numbers ?? []).filter((n) => allowed.has(n)) props.form.numbers = (props.form.numbers ?? []).filter((n) => allowed.has(n))
} catch (e) { } 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) { function isSelected(mobile?: string) {
@@ -162,10 +282,26 @@ function resetAll() {
groupNumbers.value = [] groupNumbers.value = []
baseGroupNumbers = [] baseGroupNumbers = []
props.form.numbers = [] props.form.numbers = []
props.form.message = ''
cursorStart = 0
cursorEnd = 0
} }
defineExpose({ defineExpose({
validateFields: () => formRef.value?.validateFields(), validateFields: () => formRef.value?.validateFields(),
resetAll resetAll,
buildFinalMessage
}) })
</script> </script>
<style scoped>
.sms-message-area {
min-height: 180px;
}
@media (min-width: 768px) {
.sms-message-area {
min-height: 280px;
}
}
</style>
+2 -1
View File
@@ -53,6 +53,7 @@ const table = ref<null | { fetch: () => void }>(null)
const smsEditorRef = ref<{ const smsEditorRef = ref<{
validateFields?: () => ReturnType<NonNullable<FormInstance['validateFields']>> validateFields?: () => ReturnType<NonNullable<FormInstance['validateFields']>>
resetAll?: () => void resetAll?: () => void
buildFinalMessage?: () => string
}>() }>()
const sendStore = useGroupSmsSendStore() const sendStore = useGroupSmsSendStore()
@@ -138,7 +139,7 @@ function ok() {
return return
} }
const text = form.value.message const text = smsEditorRef.value?.buildFinalMessage?.() ?? form.value.message
addSMSVisible.value = false addSMSVisible.value = false
resetData() resetData()
await sendStore.start(numbers, text) await sendStore.start(numbers, text)