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>
<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>
+2 -1
View File
@@ -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)