edit SMSpanel

This commit is contained in:
2026-07-04 14:21:27 +03:30
parent 40d04a2dfb
commit f7cc5180b1
19 changed files with 2745 additions and 249 deletions
+300 -32
View File
@@ -1,46 +1,314 @@
# admin-vue3 # Khanegi — پنل ادمین (Admin Panel)
This template should help get you started developing with Vue 3 in Vite. داشبورد **مدیریتی فارسی RTL** برای اپراتورهای پلتفرم معاملات طلا و آبشده: مشتریان، محصولات، قیمت‌گذاری، سفارشات، کیف پول، برداشت، حسابداری (ته‌حساب)، SMS و پیکربندی.
## Recommended IDE Setup | مورد | مقدار |
|------|--------|
| نام پکیج | `admin-vue3` |
| نسخه | `0.0.1` |
| فریم‌ورک | Vue 3 + TypeScript |
| بیلد | Vite 4 |
| UI | Ant Design Vue 3 (RTL) |
| Base path | `/admin` |
| پورت dev | `3600` |
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). ---
## Type Support for `.vue` Imports in TS ## نقش در معماری Khanegi
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. ```
Khanegi/
├── userModular/ ← پنل مشتری
├── admin/ ← این پروژه (پنل مدیریت)
└── server/ ← API اصلی (Laravel)
```
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: | اتصال | پروتکل | آدرس |
|--------|--------|------|
| API | HTTPS + JWT | `{node_server}/admin/*`, `/auth/*` |
| Tenant config | HTTPS | `/get_user` (قبل از لود اپ) |
| WebSocket (اختیاری) | WS | از تنظیمات `wss_link` |
1. Disable the built-in TypeScript Extension ---
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration ## پیش‌نیازها
See [Vite Configuration Reference](https://vitejs.dev/config/). - Node.js 18+
- npm، yarn یا pnpm
## Customer Setup ---
```sh ## نصب و اجرا
```bash
cd admin
npm install npm install
``` npm run dev # http://localhost:3600/admin/
npm run build # vite build → dist/
### Compile and Hot-Reload for Development npm run build-type-check # vue-tsc + build
npm run preview
```sh npm run type-check
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint npm run lint
npm run format
``` ```
**توجه:** در dev، `config-loader.js` به URLهای production `/get_user` متصل می‌شود. بدون دسترسی به آن‌ها، اپ با config خالی لود می‌شود (timeout 12 ثانیه).
### Deploy
خروجی `dist/` زیر مسیر `/admin` سرو می‌شود. `public/.htaccess` درخواست‌های SPA را به `/admin/index.html` هدایت می‌کند.
---
## ساختار پروژه
```
admin/
├── config.ts # API URLs از window.__APP_CONFIG__
├── index.html # loads config-loader.js
├── vite.config.ts # base: '/admin', port: 3600
├── src/
│ ├── main.ts # bootstrap, auth guard, dynamic routes
│ ├── config-loader.js # fetch tenant config + version check
│ ├── App.vue # RTL shell
│ ├── api/ # 23 ماژول REST
│ ├── pages/ # صفحات مدیریتی
│ │ ├── dashboard/ # میز کار
│ │ ├── menuPages/ # ماژول‌های اصلی
│ │ ├── balance/ # تراز (پنجره جدا)
│ │ └── mobileMenu/ # نمای موبایل
│ ├── router/
│ │ ├── index.ts # setupRoutes(), permission filter
│ │ └── routes/ # ماژول‌های مسیر (18 فایل)
│ ├── store/ # userStore, configStore
│ ├── utils/ # config, deployAdmin
│ └── core/ # فریم‌ورک مشترک (git submodule)
│ ├── layouts/
│ ├── components/
│ ├── router/
│ └── config/vite.ts
└── public/.htaccess
```
### Path Aliases
| Alias | مسیر |
|-------|------|
| `@/` | `src/` |
| `@core/` | `src/core/` |
| `@root/` | ریشه admin |
`src/core` یک submodule قابل به‌روزرسانی است (Admin-Vue3-Core):
```bash
git submodule update --remote --recursive
```
---
## Tech Stack
| لایه | تکنولوژی |
|------|-----------|
| Framework | Vue 3 (Composition API, `<script setup>`) |
| Build | Vite 4 |
| Router | Vue Router 4 |
| State | Pinia |
| UI | Ant Design Vue 3 |
| Styling | Less + UnoCSS |
| HTTP | Axios (`src/core/utils/useAxios.ts`) |
| Charts | ECharts, ApexCharts |
| Dates | moment-jalaali, jalaliday |
| PWA | vite-plugin-pwa |
---
## ماژول‌های مدیریتی
### میز کار (`dashboard`)
- آمار کلی، نمودار مالی
- `GET/POST /admin/dashboard`, `POST /admin/dashboard/finance`
### مشتریان (`customers`)
- لیست مشتریان، بایگانی
- احراز هویت (فقط tenant `garoosi`)
- دارایی مشتری (`/admin/customer/{id}/assets`)
### محصولات (`products`)
- CRUD محصولات، بایگانی، دسته‌بندی
- Quick actions: فعال/غیرفعال، بستن خرید/فروش، sync نمایش
- `resource /admin/product`
### کیف پول (`wallet`)
- شارژ ریال/طلا، لیست کیف پول‌ها
- تراکنش‌ها و به‌روزرسانی
### گزارشات (`transactions`)
- گزارش تراکنش‌ها
### حساب‌ها (`accounts`)
- حساب‌های بانکی مشتریان
- تأیید/رد (`POST /admin/account/sts`)
### سفارشات (`orders`)
- لیست سفارشات عمده و تک
- `POST /admin/order/table`, `POST /admin/order/single/table`
### برداشت و واریز (`withdraw`)
- مدیریت درخواست‌های برداشت
- فاکتور (`/withdraw/factor/:id`)
### پیکربندی (`settings`)
- تنظیمات عمومی (`configuration`)
- تاریخچه قیمت (`pricing`)
- تنظیمات پیشرفته (`advanced-configuration`) — فقط deploy admin
- بنرها (`banners`)
- هشدار قیمت (`priceTriggers`)
- پیامک‌ها (`sms`)
- کارت هدیه (`giftCard`) — tenant `garoosi` یا حالت store
- تخفیفات (`discounts`) — فقط tenant `goldsun`
- مدیران (`admins`)
- حسابداری / ته‌حساب (`accounting`)
### تراز (`balance`)
- صفحه balance در پنجره جدا — hidden از منو
---
## احراز هویت و دسترسی
### Boot Sequence
1. `index.html` → `config-loader.js`
2. Fetch `/get_user` و `/get_version` (failover URLs)
3. `window.__APP_CONFIG__` set
4. `main.ts` → `GET /admin/me`
5. `setupRoutes(permissions)` — فیلتر مسیرها
### Auth
| مورد | مقدار |
|------|--------|
| Login | `POST /auth/login` |
| Payload | `{ mobile, password?, provider: 'user' }` |
| OTP | `POST /auth/verify` |
| Token | `peransa_token` (localStorage) |
| Profile | `GET /admin/me` |
### Authorization
- **Permissions:** `userInfo.role.permissions[]`
- **Admin کامل:** `is_admin` → `permissionsKeys = ['*']`
- **Route meta:** `permissionsKey` — فیلتر recursive در `setupRoutes()`
- **Tenant filter:** منو بر اساس `is_store`, `garoosi`, `goldsun`
- **Deploy admin:** زیرمنوی «تنظیمات پیشرفته» فقط برای شماره‌های deploy admin
---
## مسیرها (Routes)
Base: `/admin`
| Route | Path | عنوان |
|-------|------|--------|
| login | `/login` | ورود |
| dashboard | `/dashboard` | میز کار |
| customers | `/customers/*` | مشتریان |
| products | `/products/*` | محصولات |
| wallet | `/wallet` | کیف پول |
| transactions | `/transactions` | گزارشات |
| accounts | `/accounts` | حساب‌ها |
| orders | `/orders` | سفارشات |
| withdraw | `/withdraw` | برداشت و واریز |
| settings | `/settings/*` | پیکربندی |
| balance | `/balance` | تراز (hidden) |
مسیر فاکتور (hidden): `/withdraw/factor/:id`
---
## API Integration
### HTTP Client
- `httpApi` / `httpProjectApi` — base: `getConfigApp('node_server')`
- Request: `Authorization: Bearer {token}`
- Response: `response.data` مستقیم
- 401: پاک کردن token و redirect به login
### API Modules
| فایل | Endpoints |
|------|-----------|
| `login.ts` | `/auth/login`, `/auth/verify` |
| `user.ts` | `/admin/me`, `/admin/user/*` |
| `customer.ts` | `/admin/customer/*` |
| `product.ts` | `/admin/product/*` |
| `price.ts` | `/admin/price/*`, `/admin/prices/*` |
| `order.ts` | `/admin/order/*` |
| `wallet.ts` | `/admin/wallet/*` |
| `tahesab.ts` | `/admin/tahesab/*` |
| `configuration.ts` | `/admin/get_settings` |
| `giftCard.ts` | `/admin/giftcard/*` |
| `accounting.ts` | `/admin/finance` |
| `sms.ts` | `/admin/messages/*` |
| `dashboard.ts` | `/admin/dashboard/*` |
---
## Runtime Config
`config-loader.js` URLهای failover:
- `//nerkh.gold/get_user`
- `//tablo.talasevin.ir/get_user`
- `//abshode-service.montgit.ir/server/get_user`
- `//212.23.201.173:8381/get_user`
- `//91.212.174.137:4040/server/get_user`
فیلدهای مورد استفاده در `config.user`:
- `name`, `avatar`, `node_server`
Cache: `localStorage.CONFIG_Success_URL`
---
## Tenant / White-label
- فیلتر منو per-tenant: `garoosi`, `goldsun`, `is_store`
- `document.title` و favicon از config
- حالت store: کارت هدیه + تخفیف، منوی متفاوت
- `prefixCls`: `hr-tamin`
---
## فایل‌های Config مهم
| فایل | نقش |
|------|-----|
| `config.ts` | tokenName (`peransa_token`), appName, prefixCls |
| `vite.config.ts` | base `/admin`, port 3600 |
| `src/core/config/vite.ts` | auto-import, Less |
| `src/config-loader.js` | tenant bootstrap |
| `src/router/index.ts` | route registration + permission filter |
| `src/store/userStore.ts` | auth + permissions |
| `src/utils/config.ts` | `getConfigApp`, `isStoreApp` |
---
## نکات توسعه
1. **Core submodule:** تغییرات مشترک در `src/core` — با دقت merge کنید.
2. **Permission-driven UI:** قبل از افزودن route، `permissionsKey` تعریف کنید.
3. **No .env:** مقادیر محیطی از `/get_user` می‌آید.
4. **Store mode:** `isStoreApp()` رفتار منو و API را تغییر می‌دهد.
5. **Mobile view:** `src/pages/mobileMenu/` برای نمای موبایل ادمین.
---
## مستندات مرتبط
- [README اپ مشتری](../userModular/README.md)
- [README سرور Laravel](../server/README.md)
- [README نمای کلی](../README.md)
+1
View File
@@ -52,6 +52,7 @@ declare module '@vue/runtime-core' {
ATable: typeof import('ant-design-vue/es')['Table'] ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane'] ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs'] ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea'] ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimePicker: typeof import('ant-design-vue/es')['TimePicker'] ATimePicker: typeof import('ant-design-vue/es')['TimePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip'] ATooltip: typeof import('ant-design-vue/es')['Tooltip']
+70
View File
@@ -0,0 +1,70 @@
import useAxios from '@core/utils/useAxios'
import type { TableInput } from '@core/type/baseTable'
export type SmsPanelOverview = {
wallet: {
balance: number
price_per_part: number
is_blocked: boolean
alert_days_sent: number
last_alert_date: string | null
}
stats: {
today: { parts: number; amount: number; messages: number }
month: { parts: number; amount: number; messages: number }
}
}
export type SmsPanelReport = {
from_date: string
to_date: string
balance: number
summary: { parts: number; amount: number; messages: number }
by_provider: Array<{
provider: string
messages: number
parts: number
amount: number
}>
}
export type SmsPanelChargeReportItem = {
id: number
amount: number
balance_after: number
note: string | null
created_by: number | null
created_at: string
}
export type SmsPanelChargeReport = {
from_date: string | null
to_date: string | null
summary: { charges: number; amount: number }
items: SmsPanelChargeReportItem[]
}
export const getSmsPanelOverview = () => useAxios.get<SmsPanelOverview>('/admin/sms-panel/overview')
export const chargeSmsWallet = (payload: { amount: number; note?: string }) =>
useAxios.post<{ message: string; balance: number }>('/admin/sms-panel/charge', payload)
export const updateSmsPrice = (price_per_part: number) =>
useAxios.put<{ message: string; price_per_part: number }>('/admin/sms-panel/price', {
price_per_part
})
export const updateSmsBlock = (is_blocked: boolean) =>
useAxios.put<{ message: string; is_blocked: boolean }>('/admin/sms-panel/block', { is_blocked })
export const getSmsPanelLogsTable = (params: TableInput) =>
useAxios.post('/admin/sms-panel/logs/table', params)
export const getSmsPanelLedgerTable = (params: TableInput) =>
useAxios.post('/admin/sms-panel/ledger/table', params)
export const getSmsPanelReport = (params?: { from_date?: string; to_date?: string }) =>
useAxios.get<SmsPanelReport>('/admin/sms-panel/report', { params })
export const getSmsPanelChargeReport = (params?: { from_date?: string; to_date?: string }) =>
useAxios.get<SmsPanelChargeReport>('/admin/sms-panel/report/charges', { params })
+3
View File
@@ -16,6 +16,9 @@ export interface User {
token_sms?: string token_sms?: string
user_type?: string user_type?: string
send_sms?:boolean send_sms?:boolean
settings?: Const[]
sms_billing_enabled?: boolean
sms_panel_access?: boolean
} }
export interface AddId { export interface AddId {
@@ -105,7 +105,7 @@ export const SYSTEM_SETTING_GROUPS: SettingGroupDef[] = [
title: 'عمومی و برند', title: 'عمومی و برند',
icon: 'vuesax-linear:home-2', icon: 'vuesax-linear:home-2',
match: (k) => match: (k) =>
/^(name_fa|name_en|app_url|admin_url|apk_download_link|banner_enabled|bace_url|server_url|logo_|title_pwa|title_fav|metal_type|main_metal|unit$|open_register|auto_active_customer|store_status|key$|is_store|group_sms|has_page_earn_money)/.test( /^(name_fa|name_en|app_url|admin_url|apk_download_link|banner_enabled|bace_url|server_url|logo_|title_pwa|title_fav|metal_type|main_metal|unit$|open_register|auto_active_customer|store_status|key$|is_store|group_sms|has_page_earn_money|sms_panel)/.test(
k k
) )
}, },
@@ -108,6 +108,7 @@ const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = {
register_referral_sms_to_referrer: 'switch', register_referral_sms_to_referrer: 'switch',
register_referral_bonus_message: 'switch', register_referral_bonus_message: 'switch',
open_register: 'switch', open_register: 'switch',
sms_panel: 'switch',
auto_active_customer: 'switch', auto_active_customer: 'switch',
close_price: 'switch', close_price: 'switch',
startDay: 'time', startDay: 'time',
@@ -268,6 +269,8 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
'نام فارسی برند که در پیامک‌ها، عنوان صفحات و بخش‌های عمومی اپ به مشتری نشان داده می‌شود.', 'نام فارسی برند که در پیامک‌ها، عنوان صفحات و بخش‌های عمومی اپ به مشتری نشان داده می‌شود.',
open_register: open_register:
'روشن: کاربران جدید می‌توانند ثبت‌نام کنند. خاموش: فقط ورود کاربران قبلی مجاز است.', 'روشن: کاربران جدید می‌توانند ثبت‌نام کنند. خاموش: فقط ورود کاربران قبلی مجاز است.',
sms_panel:
'روشن: پنل پیامک و کیف پول billing فعال است. خاموش: پیامک‌ها مثل قبل ارسال می‌شوند بدون billing.',
auto_active_customer: auto_active_customer:
'روشن: حساب بلافاصله پس از ثبت‌نام فعال می‌شود. خاموش: نیاز به تأیید دستی شما در پنل دارد.', 'روشن: حساب بلافاصله پس از ثبت‌نام فعال می‌شود. خاموش: نیاز به تأیید دستی شما در پنل دارد.',
close_price: close_price:
@@ -436,6 +439,7 @@ export const KEY_LABEL_FALLBACK: Record<string, string> = {
referral_id: 'کد معرف اجباری', referral_id: 'کد معرف اجباری',
referral_gift_on_first_order: 'پاداش معرف پس از اولین سفارش', referral_gift_on_first_order: 'پاداش معرف پس از اولین سفارش',
open_register: 'ثبت‌نام باز', open_register: 'ثبت‌نام باز',
sms_panel: 'پنل پیامک (کیف پول)',
auto_active_customer: 'فعال‌سازی خودکار پس از ثبت‌نام', auto_active_customer: 'فعال‌سازی خودکار پس از ثبت‌نام',
balance_report_exclude_customer_id: 'حذف مشتری از گزارش تراز', balance_report_exclude_customer_id: 'حذف مشتری از گزارش تراز',
chart_use_local_data: 'نمودار از داده داخلی', chart_use_local_data: 'نمودار از داده داخلی',
+6
View File
@@ -25,6 +25,7 @@ import BasicMenu from '@/components/BasicMenu/index.vue'
import { useRouterStore } from '@core/stores/router' import { useRouterStore } from '@core/stores/router'
import { useUserStore } from '@/store/userStore' import { useUserStore } from '@/store/userStore'
import { isDeployAdmin } from '@/utils/deployAdmin' import { isDeployAdmin } from '@/utils/deployAdmin'
import { hasSmsPanelAccess, isSmsBillingEnabled } from '@/utils/smsPanelAccess'
import { bannerEnabledSetting } from '@/utils/config' import { bannerEnabledSetting } from '@/utils/config'
import { get } from 'lodash' import { get } from 'lodash'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
@@ -38,6 +39,11 @@ const menu = computed(() => {
return children return children
.filter((child) => !child.meta?.hideMenu) .filter((child) => !child.meta?.hideMenu)
.filter((child) => child.name !== 'banners' || bannerEnabledSetting.value) .filter((child) => child.name !== 'banners' || bannerEnabledSetting.value)
.filter(
(child) =>
!child.meta?.requiresSmsPanelAccess ||
(isSmsBillingEnabled(userStore.userInfo) && hasSmsPanelAccess(userStore.userInfo))
)
.filter( .filter(
(child) => (child) =>
!child.meta?.deployAdminOnly || isDeployAdmin(userStore.userInfo?.mobile) !child.meta?.deployAdminOnly || isDeployAdmin(userStore.userInfo?.mobile)
@@ -0,0 +1,41 @@
<template>
<span
class="sms-log-body-expandable block max-w-full text-[0.8125rem] leading-relaxed text-[var(--admin-text-muted)]"
:class="expanded ? 'sms-log-body-expandable--open' : 'sms-log-body-expandable--clamped'"
role="button"
tabindex="0"
@click="toggle"
@keydown.enter.prevent="toggle"
>
{{ text || '—' }}
</span>
</template>
<script setup lang="ts">
const props = defineProps<{
text?: string | null
}>()
const expanded = ref(false)
function toggle() {
if (!props.text) return
expanded.value = !expanded.value
}
</script>
<style scoped>
.sms-log-body-expandable--clamped {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
cursor: pointer;
}
.sms-log-body-expandable--open {
white-space: pre-wrap;
word-break: break-word;
cursor: pointer;
}
</style>
@@ -0,0 +1,40 @@
<template>
<div class="sms-panel-table min-w-0 w-full">
<BaseTable v-bind="tblProps" @register="register" />
</div>
</template>
<script lang="ts" setup>
import type { TableInput } from '@core/type/baseTable'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import { ledgerCols } from '../hooks/ledgerColumns'
import { getSmsPanelLedgerTable } from '@/api/smsPanel'
const props = defineProps<{
fromDate?: string
toDate?: string
}>()
const [register, reload] = useBaseTable()
const tblProps = computed(() => ({
columns: ledgerCols,
api: (params: TableInput) =>
getSmsPanelLedgerTable({
...params,
from_date: props.fromDate || undefined,
to_date: props.toDate || undefined
})
}))
function fetch() {
reload()
}
watch(
() => [props.fromDate, props.toDate],
() => fetch()
)
defineExpose({ fetch })
</script>
@@ -0,0 +1,40 @@
<template>
<div class="sms-panel-table min-w-0 w-full">
<BaseTable v-bind="tblProps" @register="register" />
</div>
</template>
<script lang="ts" setup>
import type { TableInput } from '@core/type/baseTable'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import { logsCols } from '../hooks/logsColumns'
import { getSmsPanelLogsTable } from '@/api/smsPanel'
const props = defineProps<{
fromDate?: string
toDate?: string
}>()
const [register, reload] = useBaseTable()
const tblProps = computed(() => ({
columns: logsCols,
api: (params: TableInput) =>
getSmsPanelLogsTable({
...params,
from_date: props.fromDate || undefined,
to_date: props.toDate || undefined
})
}))
function fetch() {
reload()
}
watch(
() => [props.fromDate, props.toDate],
() => fetch()
)
defineExpose({ fetch })
</script>
@@ -0,0 +1,126 @@
import type { BaseTableColumn } from '@core/type/baseTable'
import { toJalali, toJalaliNoTime } from '@core/utils/useDataTime'
import { Tooltip } from 'ant-design-vue'
import CustomTag from '@/components/CustomTag.vue'
import SmsLogBodyExpandable from '../components/SmsLogBodyExpandable.vue'
function formatJalaliDateTime(value: string | null | undefined, empty = '—') {
if (!value) return empty
const full = toJalali(value)
const short = toJalaliNoTime(value)
return (
<Tooltip title={full} placement="top">
<span class="cursor-help whitespace-nowrap">{short}</span>
</Tooltip>
)
}
function formatAmount(value: number | null | undefined) {
if (value == null) return '—'
const n = Number(value)
const formatted = Math.abs(n).toLocaleString('fa-IR')
if (n > 0) return `+${formatted}`
if (n < 0) return `-${formatted}`
return formatted
}
const typeLabels: Record<string, { text: string; bg: string; fg: string }> = {
charge: { text: 'شارژ', bg: '#dcfce7', fg: '#16a34a' },
debit: { text: 'کسر', bg: '#fee2e2', fg: '#dc2626' },
alert_debit: { text: 'هشدار', bg: '#ffedd5', fg: '#ea580c' }
}
export const ledgerCols: BaseTableColumn[] = [
{
title: 'ردیف',
dataIndex: 'id',
key: 'row_index',
filterable: false,
hideInCard: true,
width: 56,
customRender: (record: any) => record.index + 1
},
{
title: 'نوع',
dataIndex: 'type',
key: 'type',
filterable: false,
cardGroup: 'summary',
cardGroupLayout: 'grid-2',
width: 88,
customRender: (ctx: any) => {
const type = String(ctx.record?.type ?? '')
const meta = typeLabels[type] ?? { text: type || '—', bg: '#f4f4f5', fg: '#52525b' }
return (
<CustomTag bg-color={meta.bg} text-color={meta.fg}>
{meta.text}
</CustomTag>
)
}
},
{
title: 'مبلغ',
dataIndex: 'amount',
key: 'amount',
filterable: false,
cardGroup: 'summary',
width: 110,
customRender: (ctx: any) => formatAmount(ctx.record?.amount)
},
{
title: 'موجودی بعد',
dataIndex: 'balance_after',
key: 'balance_after',
filterable: false,
cardGroup: 'summary',
width: 110,
customRender: (ctx: any) => Number(ctx.record?.balance_after ?? 0).toLocaleString('fa-IR')
},
{
title: 'پارت',
dataIndex: 'parts',
key: 'parts',
type: 'Number',
hideInCard: true,
width: 72
},
{
title: 'قیمت پارت',
dataIndex: 'price_per_part',
key: 'price_per_part',
filterable: false,
hideInCard: true,
width: 96,
customRender: (record: any) => {
const v = record.record.price_per_part
return v != null ? Number(v).toLocaleString('fa-IR') : '—'
}
},
{
title: 'توضیح',
dataIndex: 'note',
key: 'note',
type: 'Text',
ellipsis: true,
cardRole: 'stacked',
cardLabel: 'توضیح',
customRender: (ctx: { record: any; mobileCard?: boolean }) => {
const note = ctx.record?.note ?? ''
if (ctx.mobileCard) {
if (!note) return <span>—</span>
return <SmsLogBodyExpandable text={note} />
}
return <span class="block max-w-full">{note || '—'}</span>
}
},
{
title: 'تاریخ',
dataIndex: 'created_at',
key: 'created_at',
type: 'DateTime',
cardRole: 'header',
cardShowRowBadge: true,
width: 118,
customRender: (ctx: any) => formatJalaliDateTime(ctx.record?.created_at)
}
]
@@ -0,0 +1,127 @@
import type { BaseTableColumn } from '@core/type/baseTable'
import { toJalali, toJalaliNoTime } from '@core/utils/useDataTime'
import { Tooltip } from 'ant-design-vue'
import CustomTag from '@/components/CustomTag.vue'
import SmsLogBodyExpandable from '../components/SmsLogBodyExpandable.vue'
function formatJalaliDateTime(value: string | null | undefined, empty = '—') {
if (!value) return empty
const full = toJalali(value)
const short = toJalaliNoTime(value)
return (
<Tooltip title={full} placement="top">
<span class="cursor-help whitespace-nowrap">{short}</span>
</Tooltip>
)
}
function formatAmount(value: number | null | undefined) {
if (value == null) return '—'
return Number(value).toLocaleString('fa-IR')
}
export const logsCols: BaseTableColumn[] = [
{
title: 'ردیف',
dataIndex: 'id',
key: 'row_index',
filterable: false,
hideInCard: true,
width: 56,
customRender: (record: any) => record.index + 1
},
{
title: 'گیرنده',
dataIndex: 'receiver',
key: 'receiver',
type: 'Text',
cardGroup: 'summary',
cardGroupLayout: 'grid-2',
width: 120
},
{
title: 'پارت',
dataIndex: 'parts',
key: 'parts',
type: 'Number',
cardGroup: 'summary',
width: 72
},
{
title: 'قیمت پارت',
dataIndex: 'price_per_part',
key: 'price_per_part',
filterable: false,
hideInCard: true,
width: 96,
customRender: (record: any) => formatAmount(record.record.price_per_part)
},
{
title: 'مبلغ',
dataIndex: 'total_amount',
key: 'total_amount',
filterable: false,
cardGroup: 'summary',
width: 96,
customRender: (record: any) => formatAmount(record.record.total_amount)
},
{
title: 'ارائه‌دهنده',
dataIndex: 'provider',
key: 'provider',
type: 'Text',
hideInCard: true,
width: 110
},
{
title: 'منبع',
dataIndex: 'source',
key: 'source',
type: 'Text',
hideInCard: true,
width: 100
},
{
title: 'نوع',
dataIndex: 'is_alert',
key: 'is_alert',
filterable: false,
cardGroup: 'summary',
width: 88,
customRender: (record: any) =>
record.record.is_alert ? (
<CustomTag bg-color="#ffedd5" text-color="#ea580c">
هشدار
</CustomTag>
) : (
<CustomTag bg-color="#dbeafe" text-color="#2563eb">
عادی
</CustomTag>
)
},
{
title: 'متن',
dataIndex: 'body',
key: 'body',
type: 'Text',
ellipsis: true,
cardRole: 'stacked',
cardLabel: 'متن پیام',
customRender: (ctx: { record: any; mobileCard?: boolean }) => {
const body = ctx.record?.body ?? ''
if (ctx.mobileCard) {
return <SmsLogBodyExpandable text={body} />
}
return <span class="block max-w-full">{body || '—'}</span>
}
},
{
title: 'تاریخ',
dataIndex: 'created_at',
key: 'created_at',
type: 'DateTime',
cardRole: 'header',
cardShowRowBadge: true,
width: 118,
customRender: (record: any) => formatJalaliDateTime(record.record.created_at)
}
]
File diff suppressed because it is too large Load Diff
+17
View File
@@ -4,6 +4,8 @@ import { useRouterStore } from '@core/stores/router'
import type { Router, RouteRecordRaw } from 'vue-router' import type { Router, RouteRecordRaw } from 'vue-router'
import { getConfigApp, isStoreApp } from '@/utils/config' import { getConfigApp, isStoreApp } from '@/utils/config'
import { isDeployAdmin } from '@/utils/deployAdmin' import { isDeployAdmin } from '@/utils/deployAdmin'
import { hasSmsPanelAccess, isSmsBillingEnabled } from '@/utils/smsPanelAccess'
import { useUserStore } from '@/store/userStore'
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
//const _router = useRouter() //const _router = useRouter()
//import { cloneDeep } from 'lodash' //import { cloneDeep } from 'lodash'
@@ -46,6 +48,20 @@ function filterRoutesByApp(routes: RouteRecordRaw, appName: string | null) {
routes.children.forEach((child) => filterRoutesByApp(child, appName)) routes.children.forEach((child) => filterRoutesByApp(child, appName))
} }
/** زیرمنوی پنل پیامک — فقط deploy admin و وقتی billing فعال است */
function filterSmsPanelRoutes(route: RouteRecordRaw) {
const user = useUserStore().userInfo
if (!route.children?.length) return
route.children = route.children.filter((child) => {
if (!child.meta?.requiresSmsPanelAccess) return true
return isSmsBillingEnabled(user) && hasSmsPanelAccess(user)
})
route.children.forEach((child) => filterSmsPanelRoutes(child))
}
/** زیرمنوی تنظیمات پیشرفته — فقط برای ادمین دیپلoyi */ /** زیرمنوی تنظیمات پیشرفته — فقط برای ادمین دیپلoyi */
function filterDeployAdminRoutes(route: RouteRecordRaw, mobile?: string | number | null) { function filterDeployAdminRoutes(route: RouteRecordRaw, mobile?: string | number | null) {
if (!route.children?.length) return if (!route.children?.length) return
@@ -90,6 +106,7 @@ function prepareRoute(route: RouteRecordRaw, appName: string | null, mobile?: st
applyAppModeMeta(route, appName) applyAppModeMeta(route, appName)
filterRoutesByApp(route, appName) filterRoutesByApp(route, appName)
filterDeployAdminRoutes(route, mobile) filterDeployAdminRoutes(route, mobile)
filterSmsPanelRoutes(route)
} }
function loadDynamicRoutes(appName: string | null, mobile?: string | number | null): RouteRecordRaw[] { function loadDynamicRoutes(appName: string | null, mobile?: string | number | null): RouteRecordRaw[] {
+18
View File
@@ -1,5 +1,6 @@
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import { isDeployAdmin } from '@/utils/deployAdmin' import { isDeployAdmin } from '@/utils/deployAdmin'
import { hasSmsPanelAccess } from '@/utils/smsPanelAccess'
import { useUserStore } from '@/store/userStore' import { useUserStore } from '@/store/userStore'
import { bannerEnabledSetting } from '@/utils/config' import { bannerEnabledSetting } from '@/utils/config'
@@ -71,6 +72,23 @@ const Setting: RouteRecordRaw = {
} }
} }
}, },
{
name: 'smsPanelMain',
path: 'sms-panel',
component: () => import('@/pages/menuPages/smsPanel/index.vue'),
meta: {
icon: 'vuesax-linear:wallet-3',
title: 'پنل پیامک',
order: 3.5,
requiresSmsPanelAccess: true
},
beforeEnter() {
const userStore = useUserStore()
if (!hasSmsPanelAccess(userStore.userInfo)) {
return { name: 'configuration' }
}
}
},
{ {
name: 'banners', name: 'banners',
path: 'banners', path: 'banners',
+14 -1
View File
@@ -3,6 +3,7 @@ import type { User } from '@/model/user'
import { getMyInformation } from '@/api/user' import { getMyInformation } from '@/api/user'
import { get } from 'lodash' import { get } from 'lodash'
import { isDeployAdmin } from '@/utils/deployAdmin' import { isDeployAdmin } from '@/utils/deployAdmin'
import { hasSmsPanelAccess, isSmsBillingEnabled } from '@/utils/smsPanelAccess'
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const userInfo = ref<User | undefined>(undefined) const userInfo = ref<User | undefined>(undefined)
@@ -32,5 +33,17 @@ export const useUserStore = defineStore('user', () => {
const isDeployAdminUser = computed(() => isDeployAdmin(userInfo.value?.mobile)) const isDeployAdminUser = computed(() => isDeployAdmin(userInfo.value?.mobile))
return { userInfo, getMyInfo, permissions, canAccess, isAdmin, isDeployAdminUser } const smsPanelAccess = computed(() => hasSmsPanelAccess(userInfo.value))
const smsBillingEnabled = computed(() => isSmsBillingEnabled(userInfo.value))
return {
userInfo,
getMyInfo,
permissions,
canAccess,
isAdmin,
isDeployAdminUser,
smsPanelAccess,
smsBillingEnabled
}
}) })
+47
View File
@@ -0,0 +1,47 @@
import type { User } from '@/model/user'
import { isDeployAdmin } from '@/utils/deployAdmin'
export const SMS_PANEL_OWNER_MOBILE = '09128081392'
export function normalizeMobile(mobile: unknown): string {
let digits = String(mobile ?? '').replace(/\D/g, '')
if (digits.startsWith('98') && digits.length === 12) {
digits = `0${digits.slice(2)}`
} else if (digits.length === 10 && digits.startsWith('9')) {
digits = `0${digits}`
}
return digits
}
function parseSettingBool(value: unknown, defaultValue = true): boolean {
if (value == null || String(value).trim() === '') {
return defaultValue
}
const normalized = String(value).trim().toLowerCase()
if (['false', '0', 'no', 'off'].includes(normalized)) {
return false
}
return true
}
/** sms_panel = false → بدون کیف پول/billing؛ ارسال مثل قبل */
export function isSmsBillingEnabled(user?: User | null): boolean {
if (!user) return false
if (user.sms_billing_enabled === false) return false
const panel = user.settings?.find((s) => s.key === 'sms_panel')?.value
return parseSettingBool(panel, true)
}
export function hasSmsPanelAccess(user?: User | null): boolean {
if (!isSmsBillingEnabled(user)) return false
if (user?.sms_panel_access === true) return true
return isDeployAdmin(user?.mobile)
}
+15 -2
View File
@@ -1,5 +1,4 @@
{ {
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": [ "include": [
"env.d.ts", "env.d.ts",
"src/**/*", "src/**/*",
@@ -8,11 +7,25 @@
"components.d.ts" "components.d.ts"
], ],
"compilerOptions": { "compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"useDefineForClassFields": true,
"jsx": "preserve",
"noImplicitThis": true,
"strict": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],
"@root/*": ["./*"], "@root/*": ["./*"],
"@core/*": ["./src/core/*"] "@core/*": ["./src/core/*"]
} }
}, }
} }
+470 -213
View File
File diff suppressed because it is too large Load Diff