added some feature for plugin
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { BankOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
|
||||
const props = defineProps<{ canWrite: boolean }>()
|
||||
|
||||
type Deposit = {
|
||||
sms_charge_card_number: string
|
||||
sms_charge_sheba: string
|
||||
sms_charge_account_holder: string
|
||||
sms_charge_note: string
|
||||
plugin_charge_note: string
|
||||
}
|
||||
|
||||
const open = ref(false)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = reactive<Deposit>({
|
||||
sms_charge_card_number: '',
|
||||
sms_charge_sheba: '',
|
||||
sms_charge_account_holder: '',
|
||||
sms_charge_note: '',
|
||||
plugin_charge_note: '',
|
||||
})
|
||||
|
||||
function apply(data: Partial<Deposit> | null | undefined) {
|
||||
form.sms_charge_card_number = String(data?.sms_charge_card_number || '')
|
||||
form.sms_charge_sheba = String(data?.sms_charge_sheba || '')
|
||||
form.sms_charge_account_holder = String(data?.sms_charge_account_holder || '')
|
||||
form.sms_charge_note = String(data?.sms_charge_note || '')
|
||||
form.plugin_charge_note = String(data?.plugin_charge_note || '')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/settings/khanegi-deposit')
|
||||
apply(data)
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'خطا در دریافت اطلاعات واریز')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(open, (visible) => {
|
||||
if (visible) void load()
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!props.canWrite) {
|
||||
message.warning('دسترسی نوشتن ندارید')
|
||||
return Promise.reject()
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const { data } = await api.patch('/settings/khanegi-deposit', { ...form })
|
||||
apply(data)
|
||||
message.success('اطلاعات واریز ذخیره شد')
|
||||
open.value = false
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
|
||||
return Promise.reject()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="catalog-actions">
|
||||
<a-button @click="open = true">
|
||||
<BankOutlined /> اطلاعات واریز
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
v-model:open="open"
|
||||
title="اطلاعات واریز خانگی"
|
||||
ok-text="ذخیره"
|
||||
cancel-text="انصراف"
|
||||
:ok-button-props="{ disabled: !canWrite }"
|
||||
:confirm-loading="saving"
|
||||
:width="'40rem'"
|
||||
wrap-class-name="sa-modal-wrap"
|
||||
destroy-on-close
|
||||
@ok="submit"
|
||||
>
|
||||
<a-spin :spinning="loading">
|
||||
<p class="field-hint">
|
||||
کارت، شبا و صاحب حساب برای همهٔ نسخههای خانگی یکی است. متن پیامک در مودال شارژ پنل ادمین میآید؛ متن افزونه در مودال واریز فروشگاه افزونه.
|
||||
</p>
|
||||
<a-form layout="vertical">
|
||||
<div class="deposit-grid">
|
||||
<a-form-item label="شماره شبا شارژ پیامک">
|
||||
<a-input
|
||||
v-model:value="form.sms_charge_sheba"
|
||||
dir="ltr"
|
||||
size="large"
|
||||
placeholder="IR…"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="شماره کارت شارژ پیامک">
|
||||
<a-input
|
||||
v-model:value="form.sms_charge_card_number"
|
||||
dir="ltr"
|
||||
size="large"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-form-item label="نام صاحب حساب شارژ پیامک">
|
||||
<a-input v-model:value="form.sms_charge_account_holder" size="large" />
|
||||
</a-form-item>
|
||||
<a-form-item label="متن راهنمای شارژ پیامک">
|
||||
<a-textarea v-model:value="form.sms_charge_note" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item label="متن واریز افزونه">
|
||||
<a-textarea v-model:value="form.plugin_charge_note" :rows="3" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.catalog-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.field-hint {
|
||||
margin: 0 0 12px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.deposit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 12px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.deposit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -21,6 +21,7 @@ const props = withDefaults(
|
||||
allowNegative?: boolean
|
||||
disabled?: boolean
|
||||
nullable?: boolean
|
||||
addonAfter?: string
|
||||
}>(),
|
||||
{
|
||||
size: 'large',
|
||||
@@ -70,21 +71,29 @@ function onInput(raw: string | number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-input
|
||||
class="money-input"
|
||||
dir="ltr"
|
||||
:size="size"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:value="display"
|
||||
allow-clear
|
||||
@update:value="onInput"
|
||||
/>
|
||||
<div class="money-input">
|
||||
<a-input
|
||||
dir="ltr"
|
||||
:size="size"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:value="display"
|
||||
:addon-after="addonAfter || undefined"
|
||||
allow-clear
|
||||
@update:value="onInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.money-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.money-input :deep(.ant-input-group-wrapper),
|
||||
.money-input :deep(.ant-input-group) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.money-input :deep(input) {
|
||||
|
||||
@@ -120,6 +120,7 @@ onMounted(loadSmsToggle)
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid #e8eef5;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-btn {
|
||||
@@ -133,6 +134,8 @@ onMounted(loadSmsToggle)
|
||||
font-family: inherit;
|
||||
color: #2d3748;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.brand-dot {
|
||||
@@ -160,6 +163,8 @@ onMounted(loadSmsToggle)
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sms-toggle {
|
||||
@@ -245,5 +250,6 @@ onMounted(loadSmsToggle)
|
||||
.user-name { display: none; }
|
||||
.topbar { gap: 8px; padding: 12px; }
|
||||
.brand-btn small { display: none; }
|
||||
.icon-btn { width: 36px; height: 36px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -267,19 +267,21 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
|
||||
<a-button type="text" @click="router.push({ name: 'home' })">
|
||||
<div class="page-head">
|
||||
<a-button type="text" class="page-head-back" @click="router.push({ name: 'home' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">مدیران</h1>
|
||||
<p class="page-sub">
|
||||
مدیریت کاربران سوپرادمین — ورود با موبایل و رمز. سطح دسترسی ماژولمحور برای مدیران جدید.
|
||||
</p>
|
||||
</div>
|
||||
<a-button v-if="canWrite" type="primary" @click="openCreate">
|
||||
<PlusOutlined /> افزودن مدیر
|
||||
</a-button>
|
||||
<div class="page-head-actions">
|
||||
<a-button v-if="canWrite" type="primary" @click="openCreate">
|
||||
<PlusOutlined /> افزودن مدیر
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
@@ -321,6 +323,7 @@ onMounted(load)
|
||||
ok-text="ذخیره"
|
||||
cancel-text="انصراف"
|
||||
width="720px"
|
||||
wrap-class-name="sa-modal-wrap"
|
||||
@ok="save"
|
||||
>
|
||||
<a-form layout="vertical" style="margin-top: 12px">
|
||||
|
||||
@@ -278,15 +278,15 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
|
||||
<a-button type="text" @click="router.push({ name: 'product-versions', params: { product: 'hamkar' } })">
|
||||
<div class="page-head">
|
||||
<a-button type="text" class="page-head-back" @click="router.push({ name: 'product-versions', params: { product: 'hamkar' } })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">ظاهر اپ همکار</h1>
|
||||
<p class="page-sub">
|
||||
{{ tenantName || 'نسخه' }}
|
||||
<template v-if="domain"> · {{ domain }}</template>
|
||||
<template v-if="domain"> · <span class="page-domain">{{ domain }}</span></template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -291,6 +291,13 @@ onMounted(load)
|
||||
gap: 8px;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.head-aside {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
.hint {
|
||||
color: #64748b;
|
||||
margin: 0 0 16px;
|
||||
|
||||
+381
-106
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||
import { ArrowRightOutlined, DeleteOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
import MoneyInput from '@/components/MoneyInput.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -23,6 +23,7 @@ withDefaults(
|
||||
type PriceRow = {
|
||||
durationDays: number
|
||||
enabled: boolean
|
||||
hasDiscount: boolean
|
||||
originalPriceRials: number
|
||||
finalPriceRials: number
|
||||
}
|
||||
@@ -53,7 +54,10 @@ const loading = ref(false)
|
||||
const savingId = ref<string | null>(null)
|
||||
const uploadingKey = ref<string | null>(null)
|
||||
const items = ref<CatalogItem[]>([])
|
||||
const q = ref('')
|
||||
const showCreate = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const selectedKey = ref<string | null>(null)
|
||||
const creating = ref(false)
|
||||
const createForm = reactive({
|
||||
settingKey: '',
|
||||
@@ -69,15 +73,60 @@ const durationLabel: Record<number, string> = {
|
||||
360: '۳۶۰ روزه',
|
||||
}
|
||||
|
||||
const selectedItem = computed(
|
||||
() => items.value.find((item) => item.settingKey === selectedKey.value) || null,
|
||||
)
|
||||
|
||||
function openItem(item: CatalogItem) {
|
||||
selectedKey.value = item.settingKey
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
showDetail.value = false
|
||||
}
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const term = q.value.trim().toLowerCase()
|
||||
if (!term) return items.value
|
||||
return items.value.filter((item) => {
|
||||
const blob = [item.titleFa, item.settingKey, item.description].join(' ').toLowerCase()
|
||||
return blob.includes(term)
|
||||
})
|
||||
})
|
||||
|
||||
function emptyPrices(): PriceRow[] {
|
||||
return DURATION_DAYS.map((durationDays) => ({
|
||||
durationDays,
|
||||
enabled: false,
|
||||
hasDiscount: false,
|
||||
originalPriceRials: 0,
|
||||
finalPriceRials: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizePrices(raw: unknown): PriceRow[] {
|
||||
const byDays = new Map<number, Record<string, any>>()
|
||||
if (Array.isArray(raw)) {
|
||||
for (const row of raw) {
|
||||
byDays.set(Number(row?.durationDays), row)
|
||||
}
|
||||
}
|
||||
return DURATION_DAYS.map((durationDays) => {
|
||||
const row = byDays.get(durationDays)
|
||||
const originalPriceRials = Number(row?.originalPriceRials ?? 0)
|
||||
const finalPriceRials = Number(row?.finalPriceRials ?? 0)
|
||||
const enabled = row?.enabled === true
|
||||
return {
|
||||
durationDays,
|
||||
enabled,
|
||||
originalPriceRials,
|
||||
finalPriceRials,
|
||||
hasDiscount: enabled && originalPriceRials > finalPriceRials,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function asCatalog(data: unknown): CatalogItem[] {
|
||||
if (!Array.isArray(data)) return []
|
||||
return (data as Array<Record<string, any>>).map((row) => ({
|
||||
@@ -89,7 +138,7 @@ function asCatalog(data: unknown): CatalogItem[] {
|
||||
? row.images.map((url: unknown) => rewriteLegacyUploadUrl(url)).filter(Boolean).slice(0, 3)
|
||||
: [],
|
||||
inStore: row?.inStore !== false,
|
||||
prices: Array.isArray(row?.prices) && row.prices.length ? row.prices : emptyPrices(),
|
||||
prices: normalizePrices(row?.prices),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -170,13 +219,44 @@ async function ensureCatalogId(item: CatalogItem): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
function onRowDiscountToggle(price: PriceRow, checked: boolean) {
|
||||
price.hasDiscount = checked
|
||||
if (!checked) {
|
||||
price.originalPriceRials = price.finalPriceRials
|
||||
return
|
||||
}
|
||||
if (Number(price.originalPriceRials) < Number(price.finalPriceRials)) {
|
||||
price.originalPriceRials = price.finalPriceRials
|
||||
}
|
||||
}
|
||||
|
||||
async function save(item: CatalogItem) {
|
||||
for (const price of item.prices) {
|
||||
if (price.enabled && (price.originalPriceRials == null || price.finalPriceRials == null)) {
|
||||
if (!price.enabled) continue
|
||||
if (price.finalPriceRials == null) {
|
||||
message.warning(`برای ${durationLabel[price.durationDays]} باید قیمت بزنید`)
|
||||
return
|
||||
}
|
||||
if (!price.hasDiscount) {
|
||||
price.originalPriceRials = price.finalPriceRials
|
||||
continue
|
||||
}
|
||||
if (price.originalPriceRials == null) {
|
||||
message.warning(`برای ${durationLabel[price.durationDays]} قیمت اصلی را بزنید`)
|
||||
return
|
||||
}
|
||||
if (Number(price.originalPriceRials) <= Number(price.finalPriceRials)) {
|
||||
message.warning(`برای ${durationLabel[price.durationDays]} قیمت اصلی باید از نهایی بیشتر باشد`)
|
||||
return
|
||||
}
|
||||
}
|
||||
const prices = item.prices.map((price) => ({
|
||||
durationDays: price.durationDays,
|
||||
enabled: price.enabled,
|
||||
originalPriceRials:
|
||||
price.enabled && price.hasDiscount ? price.originalPriceRials : price.finalPriceRials,
|
||||
finalPriceRials: price.finalPriceRials,
|
||||
}))
|
||||
savingId.value = item.settingKey
|
||||
try {
|
||||
const id = await ensureCatalogId(item)
|
||||
@@ -185,7 +265,7 @@ async function save(item: CatalogItem) {
|
||||
titleFa: item.titleFa,
|
||||
description: item.description,
|
||||
images: item.images || [],
|
||||
prices: item.prices,
|
||||
prices,
|
||||
})
|
||||
message.success('ذخیره شد')
|
||||
await load()
|
||||
@@ -266,109 +346,189 @@ onMounted(load)
|
||||
<div :class="embedded ? 'catalog-embed' : 'page-shell'">
|
||||
<div
|
||||
v-if="!embedded"
|
||||
style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px"
|
||||
class="page-head"
|
||||
>
|
||||
<a-button type="text" @click="router.push({ name: 'plugin-panel' })">
|
||||
<a-button type="text" class="page-head-back" @click="router.push({ name: 'plugin-panel' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">قیمت فروشگاه خانگی</h1>
|
||||
<p class="page-sub">برای همهٔ نسخههای خانگی یکی است. مدت خاموش در ادمین دیده نمیشود.</p>
|
||||
</div>
|
||||
<a-button v-if="canWrite" type="primary" @click="showCreate = true">
|
||||
<PlusOutlined /> تنظیم جدید
|
||||
</a-button>
|
||||
<div class="page-head-actions">
|
||||
<a-button v-if="canWrite" type="primary" @click="showCreate = true">
|
||||
<PlusOutlined /> تنظیم جدید
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="catalog-embed-bar">
|
||||
<p class="catalog-hint">
|
||||
همهٔ کلیدهای دسترسی قالب خانگی. مدت خاموش در ادمین ماژول دیده نمیشود.
|
||||
</p>
|
||||
<a-button v-if="canWrite" type="primary" size="small" @click="showCreate = true">
|
||||
</div>
|
||||
|
||||
<div class="catalog-toolbar">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
class="catalog-search"
|
||||
placeholder="جستجو عنوان، کلید یا توضیح"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-button
|
||||
v-if="canWrite && embedded"
|
||||
type="primary"
|
||||
class="catalog-add"
|
||||
@click="showCreate = true"
|
||||
>
|
||||
<PlusOutlined /> تنظیم جدید
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<p class="catalog-list-hint">روی کارت بزنید تا توضیح، عکس و قیمت باز شود.</p>
|
||||
<div class="catalog-grid">
|
||||
<article v-for="item in items" :key="item.settingKey" class="catalog-card">
|
||||
<a-form layout="vertical" class="catalog-form">
|
||||
<a-form-item label="عنوان" required>
|
||||
<a-input v-model:value="item.titleFa" :disabled="!canWrite" placeholder="مثلاً ماژول جدید" />
|
||||
</a-form-item>
|
||||
<a-form-item label="کلید تنظیمات">
|
||||
<a-input :value="item.settingKey" disabled dir="ltr" />
|
||||
</a-form-item>
|
||||
<a-form-item label="توضیح">
|
||||
<a-textarea v-model:value="item.description" :rows="4" :maxlength="4000" :disabled="!canWrite" />
|
||||
</a-form-item>
|
||||
<a-form-item label="عکسها">
|
||||
<p class="img-hint">حداکثر سه عکس. در ادمین ماژول عکس اول روی کارت است.</p>
|
||||
<div class="img-slots">
|
||||
<div v-for="(url, index) in (item.images || [])" :key="url + index" class="img-slot">
|
||||
<img :src="url" alt="" />
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
size="small"
|
||||
danger
|
||||
class="img-slot-del"
|
||||
@click="removeSlot(item, index)"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
<a-upload
|
||||
v-if="canWrite && (item.images || []).length < 3"
|
||||
name="file"
|
||||
list-type="picture-card"
|
||||
:show-upload-list="false"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
:custom-request="slotUploader(item)"
|
||||
:disabled="uploadingKey === item.settingKey"
|
||||
>
|
||||
<div class="img-add">
|
||||
<PlusOutlined />
|
||||
<span>{{ uploadingKey === item.settingKey ? '…' : 'آپلود' }}</span>
|
||||
</div>
|
||||
</a-upload>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="به فروشگاه افزونه اضافه شود">
|
||||
<a-switch v-model:checked="item.inStore" :disabled="!canWrite" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<div v-for="price in item.prices" :key="price.durationDays" class="price-row">
|
||||
<a-switch v-model:checked="price.enabled" :disabled="!canWrite" />
|
||||
<span class="dur">{{ durationLabel[price.durationDays] }}</span>
|
||||
<div class="price-fields" :class="{ dim: !price.enabled }">
|
||||
<label>
|
||||
اصلی
|
||||
<MoneyInput v-model:value="price.originalPriceRials" :disabled="!canWrite || !price.enabled" />
|
||||
</label>
|
||||
<label>
|
||||
نهایی
|
||||
<MoneyInput v-model:value="price.finalPriceRials" :disabled="!canWrite || !price.enabled" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
type="primary"
|
||||
block
|
||||
:loading="savingId === item.settingKey"
|
||||
@click="save(item)"
|
||||
>
|
||||
ذخیره
|
||||
</a-button>
|
||||
</article>
|
||||
<div v-if="!items.length" class="empty-hint">کلید دسترسیای در قالب نیست</div>
|
||||
<button
|
||||
v-for="item in filteredItems"
|
||||
:key="item.settingKey"
|
||||
type="button"
|
||||
class="catalog-card"
|
||||
@click="openItem(item)"
|
||||
>
|
||||
<strong>{{ item.titleFa || 'بدون عنوان' }}</strong>
|
||||
<span class="catalog-key" dir="ltr">{{ item.settingKey }}</span>
|
||||
</button>
|
||||
<div v-if="!filteredItems.length" class="empty-hint">
|
||||
{{ items.length ? 'نتیجهای برای این جستجو نیست' : 'کلید دسترسیای در قالب نیست' }}
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-modal
|
||||
v-model:open="showDetail"
|
||||
:title="selectedItem?.titleFa || 'جزئیات افزونه'"
|
||||
width="40rem"
|
||||
wrap-class-name="sa-modal-wrap"
|
||||
:footer="null"
|
||||
@cancel="closeDetail"
|
||||
>
|
||||
<div v-if="selectedItem" class="catalog-detail">
|
||||
<a-form layout="vertical" class="catalog-form">
|
||||
<a-form-item label="عنوان" required>
|
||||
<a-input v-model:value="selectedItem.titleFa" :disabled="!canWrite" placeholder="مثلاً ماژول جدید" />
|
||||
</a-form-item>
|
||||
<a-form-item label="کلید تنظیمات">
|
||||
<a-input :value="selectedItem.settingKey" disabled dir="ltr" />
|
||||
</a-form-item>
|
||||
<a-form-item label="توضیح">
|
||||
<a-textarea v-model:value="selectedItem.description" :rows="4" :maxlength="4000" :disabled="!canWrite" />
|
||||
</a-form-item>
|
||||
<a-form-item label="عکسها">
|
||||
<p class="img-hint">حداکثر سه عکس. در ادمین ماژول عکس اول روی کارت است.</p>
|
||||
<div class="img-slots">
|
||||
<div v-for="(url, index) in (selectedItem.images || [])" :key="url + index" class="img-slot">
|
||||
<img :src="url" alt="" />
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
size="small"
|
||||
danger
|
||||
class="img-slot-del"
|
||||
@click="removeSlot(selectedItem, index)"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
<a-upload
|
||||
v-if="canWrite && (selectedItem.images || []).length < 3"
|
||||
name="file"
|
||||
list-type="picture-card"
|
||||
:show-upload-list="false"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
:custom-request="slotUploader(selectedItem)"
|
||||
:disabled="uploadingKey === selectedItem.settingKey"
|
||||
>
|
||||
<div class="img-add">
|
||||
<PlusOutlined />
|
||||
<span>{{ uploadingKey === selectedItem.settingKey ? '…' : 'آپلود' }}</span>
|
||||
</div>
|
||||
</a-upload>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="به فروشگاه افزونه اضافه شود">
|
||||
<a-switch v-model:checked="selectedItem.inStore" :disabled="!canWrite" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<section class="price-block">
|
||||
<div class="price-block-head">
|
||||
<strong>قیمت اشتراک</strong>
|
||||
<span>تومان — تیک تخفیف فقط همان مدت را در فروشگاه خط میخورد</span>
|
||||
</div>
|
||||
<div class="price-list">
|
||||
<article
|
||||
v-for="price in selectedItem.prices"
|
||||
:key="price.durationDays"
|
||||
class="price-item"
|
||||
:class="{ off: !price.enabled }"
|
||||
>
|
||||
<div class="price-item-top">
|
||||
<div class="price-item-id">
|
||||
<a-switch
|
||||
v-model:checked="price.enabled"
|
||||
size="small"
|
||||
:disabled="!canWrite"
|
||||
/>
|
||||
<strong>{{ durationLabel[price.durationDays] }}</strong>
|
||||
</div>
|
||||
<a-checkbox
|
||||
:checked="price.hasDiscount"
|
||||
:disabled="!canWrite || !price.enabled"
|
||||
@change="(e: any) => onRowDiscountToggle(price, !!e?.target?.checked)"
|
||||
>
|
||||
تخفیف
|
||||
</a-checkbox>
|
||||
</div>
|
||||
<div class="price-item-grid" :class="{ split: price.hasDiscount }">
|
||||
<label v-if="price.hasDiscount">
|
||||
<span>اصلی</span>
|
||||
<MoneyInput
|
||||
v-model:value="price.originalPriceRials"
|
||||
size="middle"
|
||||
addon-after="تومان"
|
||||
:disabled="!canWrite || !price.enabled"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ price.hasDiscount ? 'نهایی' : 'قیمت' }}</span>
|
||||
<MoneyInput
|
||||
v-model:value="price.finalPriceRials"
|
||||
size="middle"
|
||||
addon-after="تومان"
|
||||
:disabled="!canWrite || !price.enabled"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
type="primary"
|
||||
block
|
||||
:loading="savingId === selectedItem.settingKey"
|
||||
@click="save(selectedItem)"
|
||||
>
|
||||
ذخیره
|
||||
</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="showCreate"
|
||||
title="تنظیم جدید"
|
||||
ok-text="ثبت"
|
||||
cancel-text="انصراف"
|
||||
wrap-class-name="sa-modal-wrap"
|
||||
:confirm-loading="creating"
|
||||
@ok="createSetting"
|
||||
>
|
||||
@@ -392,24 +552,48 @@ onMounted(load)
|
||||
|
||||
<style scoped>
|
||||
.catalog-embed-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.catalog-hint {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.catalog-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.catalog-search {
|
||||
width: 100%;
|
||||
}
|
||||
.catalog-add {
|
||||
width: 100%;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.catalog-toolbar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.catalog-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.catalog-add {
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
.catalog-list-hint {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.82rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.catalog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@@ -418,12 +602,42 @@ onMounted(load)
|
||||
.catalog-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
}
|
||||
.catalog-card {
|
||||
appearance: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8eef5;
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
gap: 4px;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
min-width: 0;
|
||||
box-shadow: 0 6px 18px rgba(23, 50, 77, 0.08);
|
||||
transition: box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.catalog-card:hover {
|
||||
box-shadow: 0 10px 24px rgba(23, 50, 77, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.catalog-card strong {
|
||||
font-size: 0.92rem;
|
||||
color: #17324d;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.catalog-key {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
overflow-wrap: anywhere;
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
.catalog-detail {
|
||||
min-width: 0;
|
||||
}
|
||||
.catalog-detail > .ant-btn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.catalog-form :deep(.ant-form-item) {
|
||||
margin-bottom: 10px;
|
||||
@@ -437,23 +651,84 @@ onMounted(load)
|
||||
padding: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.price-row {
|
||||
.field-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.price-block {
|
||||
display: grid;
|
||||
grid-template-columns: auto 4.5rem 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.dur { font-weight: 700; font-size: 0.82rem; }
|
||||
.price-fields {
|
||||
.price-block-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
gap: 2px;
|
||||
}
|
||||
.price-fields.dim { opacity: 0.45; }
|
||||
.price-fields label {
|
||||
.price-block-head strong {
|
||||
font-size: 0.82rem;
|
||||
color: #17324d;
|
||||
}
|
||||
.price-block-head span {
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.price-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.price-item {
|
||||
border: 1px solid #e8eef5;
|
||||
border-radius: 12px;
|
||||
padding: 8px 10px;
|
||||
background: #fff;
|
||||
min-width: 0;
|
||||
}
|
||||
.price-item.off {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.price-item.off .price-item-grid {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.price-item-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.price-item-id {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.price-item-id strong {
|
||||
font-size: 0.82rem;
|
||||
color: #17324d;
|
||||
}
|
||||
.price-item-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.price-item-grid.split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.price-item-grid.split {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
.price-item-grid label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
min-width: 0;
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.img-hint {
|
||||
|
||||
@@ -195,11 +195,11 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
|
||||
<a-button type="text" @click="router.push({ name: 'home' })">
|
||||
<div class="page-head">
|
||||
<a-button type="text" class="page-head-back" @click="router.push({ name: 'home' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">پنل افزونه</h1>
|
||||
<p class="page-sub">
|
||||
اشتراک افزونههای نسخهها — هر نسخه میتواند چند افزونه داشته باشد یا هیچکدام ·
|
||||
@@ -220,7 +220,6 @@ onMounted(() => {
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو نام، دامنه یا slug"
|
||||
style="flex: 1; min-width: 180px"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
@@ -402,6 +401,13 @@ onMounted(() => {
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.main-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.main-tabs button { width: 100%; }
|
||||
}
|
||||
.main-tabs button {
|
||||
appearance: none;
|
||||
border: 1px solid #d7e0ea;
|
||||
@@ -485,9 +491,14 @@ onMounted(() => {
|
||||
|
||||
.tenant-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr));
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.tenant-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-card {
|
||||
appearance: none;
|
||||
@@ -500,6 +511,13 @@ onMounted(() => {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: #17324d;
|
||||
min-width: 0;
|
||||
box-shadow: 0 6px 16px rgba(23, 50, 77, 0.08);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.tenant-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 22px rgba(23, 50, 77, 0.12);
|
||||
}
|
||||
|
||||
.sms-toggle {
|
||||
@@ -538,9 +556,12 @@ onMounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tenant-card strong { font-size: 0.95rem; }
|
||||
.tenant-card strong {
|
||||
font-size: 0.95rem;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.tenant-card span,
|
||||
.tenant-card small { opacity: 0.92; font-size: 0.82rem; }
|
||||
.tenant-card small { opacity: 0.92; font-size: 0.82rem; overflow-wrap: break-word; }
|
||||
|
||||
.tenant-card.healthy { background: linear-gradient(135deg, #dff8ea, #c3efd7); }
|
||||
.tenant-card.warning { background: linear-gradient(135deg, #fff4c7, #ffe39a); }
|
||||
|
||||
+300
-202
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||
import MoneyInput from '@/components/MoneyInput.vue'
|
||||
@@ -47,6 +47,7 @@ const storeToggleLoading = ref(false)
|
||||
const pluginStoreEnabled = computed(() => tenant.value?.pluginStoreEnabled === true)
|
||||
const selectedId = ref<string | null>(null)
|
||||
const showCreate = ref(false)
|
||||
const showDetail = ref(false)
|
||||
|
||||
const tenant = ref<{
|
||||
id: string
|
||||
@@ -96,6 +97,24 @@ const renewForm = reactive({
|
||||
const tenantId = computed(() => String(route.params.tenantId || ''))
|
||||
|
||||
const selected = computed(() => plugins.value.find((p) => p.id === selectedId.value) || null)
|
||||
const activePlugins = computed(() => plugins.value.filter((p) => p.isActive !== false))
|
||||
const inactivePlugins = computed(() => plugins.value.filter((p) => p.isActive === false))
|
||||
const pluginQuery = ref('')
|
||||
|
||||
function pluginMatches(plugin: PluginItem, q: string) {
|
||||
if (!q) return true
|
||||
return [plugin.name, plugin.specs, plugin.description, plugin.settingKey, plugin.summary?.expiryLabel]
|
||||
.some((x) => String(x || '').toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
const filteredActivePlugins = computed(() => {
|
||||
const q = pluginQuery.value.trim().toLowerCase()
|
||||
return activePlugins.value.filter((p) => pluginMatches(p, q))
|
||||
})
|
||||
const filteredInactivePlugins = computed(() => {
|
||||
const q = pluginQuery.value.trim().toLowerCase()
|
||||
return inactivePlugins.value.filter((p) => pluginMatches(p, q))
|
||||
})
|
||||
|
||||
const planSelectOptions = computed(() =>
|
||||
(planOptions.value.length
|
||||
@@ -138,6 +157,15 @@ function selectPlugin(id: string) {
|
||||
if (plugin) fillForm(plugin)
|
||||
}
|
||||
|
||||
function openPlugin(id: string) {
|
||||
selectPlugin(id)
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
showDetail.value = false
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!tenantId.value) return
|
||||
loading.value = true
|
||||
@@ -148,14 +176,13 @@ async function loadDetail() {
|
||||
planOptions.value = data.planOptions || []
|
||||
catalogKeys.value = data.catalogKeys || []
|
||||
|
||||
const keep =
|
||||
selectedId.value && plugins.value.some((p) => p.id === selectedId.value)
|
||||
? selectedId.value
|
||||
: plugins.value[0]?.id || null
|
||||
selectedId.value = keep
|
||||
if (keep) {
|
||||
const plugin = plugins.value.find((p) => p.id === keep)
|
||||
if (showDetail.value && selectedId.value) {
|
||||
const plugin = plugins.value.find((p) => p.id === selectedId.value)
|
||||
if (plugin) fillForm(plugin)
|
||||
else {
|
||||
selectedId.value = null
|
||||
showDetail.value = false
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در دریافت افزونهها')
|
||||
@@ -196,7 +223,7 @@ async function createPlugin() {
|
||||
createForm.settingKey = undefined
|
||||
createForm.autoCutOnExpiry = false
|
||||
await loadDetail()
|
||||
if (data?.plugin?.id) selectPlugin(data.plugin.id)
|
||||
if (data?.plugin?.id) openPlugin(data.plugin.id)
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ثبت ناموفق')
|
||||
@@ -275,6 +302,7 @@ function confirmDelete() {
|
||||
await api.delete(`/plugins/items/${selected.value!.id}`)
|
||||
message.success('حذف شد')
|
||||
selectedId.value = null
|
||||
showDetail.value = false
|
||||
await loadDetail()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'حذف ناموفق')
|
||||
@@ -310,211 +338,253 @@ onMounted(loadDetail)
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
|
||||
<a-button type="text" @click="router.push({ name: 'plugin-panel' })">
|
||||
<div class="page-head">
|
||||
<a-button type="text" class="page-head-back" @click="router.push({ name: 'plugin-panel' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">{{ tenant?.nameFa || 'افزونههای نسخه' }}</h1>
|
||||
<p class="page-sub">
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} · {{ tenant?.domain }}
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }}
|
||||
<template v-if="tenant?.domain">
|
||||
· <span class="page-domain">{{ tenant.domain }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="isKhanegi && canWrite"
|
||||
class="sms-toggle"
|
||||
:class="{ off: !pluginStoreEnabled }"
|
||||
>
|
||||
<span class="sms-toggle-label">فروشگاه افزونه</span>
|
||||
<a-switch
|
||||
:checked="pluginStoreEnabled"
|
||||
:loading="storeToggleLoading"
|
||||
checked-children="روشن"
|
||||
un-checked-children="خاموش"
|
||||
@change="onStoreToggle"
|
||||
/>
|
||||
<div class="page-head-actions">
|
||||
<div
|
||||
v-if="isKhanegi && canWrite"
|
||||
class="sms-toggle"
|
||||
:class="{ off: !pluginStoreEnabled }"
|
||||
>
|
||||
<span class="sms-toggle-label">فروشگاه افزونه</span>
|
||||
<a-switch
|
||||
:checked="pluginStoreEnabled"
|
||||
:loading="storeToggleLoading"
|
||||
checked-children="روشن"
|
||||
un-checked-children="خاموش"
|
||||
@change="onStoreToggle"
|
||||
/>
|
||||
</div>
|
||||
<a-button v-if="canWrite" type="primary" @click="showCreate = true">
|
||||
<PlusOutlined /> افزونه جدید
|
||||
</a-button>
|
||||
</div>
|
||||
<a-button v-if="canWrite" type="primary" @click="showCreate = true">
|
||||
<PlusOutlined /> افزونه جدید
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<TenantPanelShortcuts :tenant-id="tenantId" current="plugin" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="layout">
|
||||
<aside class="plugin-list">
|
||||
<div class="list-head">افزونهها ({{ plugins.length }})</div>
|
||||
<section class="plugin-list">
|
||||
<div class="list-head">
|
||||
<h2>افزونههای فعال</h2>
|
||||
<p>روی هر کارت بزنید تا جزئیات باز شود و بتوانید ذخیره یا تمدید کنید.</p>
|
||||
</div>
|
||||
<a-input
|
||||
v-model:value="pluginQuery"
|
||||
allow-clear
|
||||
class="plugin-search"
|
||||
placeholder="جستجو نام یا مشخصات"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<div class="plugin-grid">
|
||||
<button
|
||||
v-for="p in plugins"
|
||||
v-for="p in filteredActivePlugins"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="plugin-card"
|
||||
:class="[toneClass(p.summary?.expiryLevel), { active: p.id === selectedId, inactive: !p.isActive }]"
|
||||
@click="selectPlugin(p.id)"
|
||||
:class="toneClass(p.summary?.expiryLevel)"
|
||||
@click="openPlugin(p.id)"
|
||||
>
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span>{{ p.summary?.expiryLabel }}</span>
|
||||
<small v-if="!p.isActive">غیرفعال</small>
|
||||
</button>
|
||||
<div v-if="!plugins.length" class="empty-hint">
|
||||
هنوز افزونهای ثبت نشده — با «افزونه جدید» شروع کنید.
|
||||
<div v-if="!filteredActivePlugins.length" class="empty-hint">
|
||||
{{ pluginQuery.trim() ? 'افزونهای با این جستجو نیست.' : 'افزونهٔ فعالی نیست — با «افزونه جدید» ثبت کنید.' }}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div v-if="selected" class="detail">
|
||||
<div class="status-banner" :class="toneClass(selected.summary?.expiryLevel)">
|
||||
<strong>{{ selected.name }} — {{ selected.summary?.expiryLabel }}</strong>
|
||||
<span v-if="selected.summary?.expiresAt">
|
||||
انقضا: {{ formatIranDate(selected.summary.expiresAt) }}
|
||||
<template v-if="selected.summary?.planLabel"> · {{ selected.summary.planLabel }}</template>
|
||||
</span>
|
||||
<template v-if="filteredInactivePlugins.length">
|
||||
<div class="list-head inactive-head">
|
||||
<h2>غیرفعال</h2>
|
||||
<p>برای دیدن جزئیات یا فعال کردن، روی کارت بزنید.</p>
|
||||
</div>
|
||||
<div class="plugin-grid">
|
||||
<button
|
||||
v-for="p in filteredInactivePlugins"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="plugin-card inactive"
|
||||
:class="toneClass(p.summary?.expiryLevel)"
|
||||
@click="openPlugin(p.id)"
|
||||
>
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span>{{ p.summary?.expiryLabel }}</span>
|
||||
<small>غیرفعال</small>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</a-spin>
|
||||
|
||||
<section class="form-section">
|
||||
<div class="section-head">
|
||||
<h3>مشخصات افزونه</h3>
|
||||
<a-button v-if="canWrite" danger type="text" @click="confirmDelete">
|
||||
<DeleteOutlined /> حذف
|
||||
</a-button>
|
||||
</div>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام افزونه" required>
|
||||
<a-input v-model:value="form.name" size="large" placeholder="مثلاً ماژول حسابداری / پنل طلا" />
|
||||
</a-form-item>
|
||||
<a-form-item label="مشخصات">
|
||||
<a-textarea v-model:value="form.specs" :rows="2" placeholder="جزئیات فنی یا یادداشت داخلی…" />
|
||||
</a-form-item>
|
||||
<a-form-item label="توضیحات">
|
||||
<a-textarea v-model:value="form.description" :rows="2" />
|
||||
</a-form-item>
|
||||
<a-form-item label="فعال">
|
||||
<a-switch v-model:checked="form.isActive" />
|
||||
</a-form-item>
|
||||
<template v-if="isKhanegi">
|
||||
<a-form-item label="کلید تنظیمات (فروشگاه)">
|
||||
<a-select
|
||||
v-model:value="form.settingKey"
|
||||
allow-clear
|
||||
placeholder="دستی — بدون فروشگاه"
|
||||
:options="catalogKeys.map((c) => ({ value: c.settingKey, label: `${c.titleFa} (${c.settingKey})` }))"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="قطع خودکار بعد از انقضا">
|
||||
<a-switch v-model:checked="form.autoCutOnExpiry" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
</section>
|
||||
<a-modal
|
||||
v-model:open="showDetail"
|
||||
:title="selected?.name || 'جزئیات افزونه'"
|
||||
width="40rem"
|
||||
wrap-class-name="sa-modal-wrap"
|
||||
:footer="null"
|
||||
@cancel="closeDetail"
|
||||
>
|
||||
<div v-if="selected" class="detail">
|
||||
<div class="status-banner" :class="toneClass(selected.summary?.expiryLevel)">
|
||||
<strong>{{ selected.name }} — {{ selected.summary?.expiryLabel }}</strong>
|
||||
<span v-if="selected.summary?.expiresAt">
|
||||
انقضا: {{ formatIranDate(selected.summary.expiresAt) }}
|
||||
<template v-if="selected.summary?.planLabel"> · {{ selected.summary.planLabel }}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>اعتبار و قیمت (وضعیت فعلی)</h3>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="12">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تاریخ شارژ (شمسی)">
|
||||
<JalaliDatePicker v-model="form.chargedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مدت شارژ">
|
||||
<a-select
|
||||
v-model:value="form.planMonths"
|
||||
size="large"
|
||||
:options="planSelectOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="قیمت دوره (تومان)">
|
||||
<MoneyInput v-model:value="form.priceRials" placeholder="مثلاً 150,000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="شروع هشدار SMS (روز مانده به انقضا)">
|
||||
<a-input-number
|
||||
v-model:value="form.alertDaysBefore"
|
||||
:min="1"
|
||||
:max="30"
|
||||
size="large"
|
||||
style="width: 160px"
|
||||
/>
|
||||
<span class="inline-hint">پیشفرض ۳ روز — روزانه یک پیام تا تمدید</span>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<div v-if="canWrite" class="sticky-actions settings-save">
|
||||
<a-button type="primary" size="large" block :loading="saving" @click="savePlugin">
|
||||
ذخیره تنظیمات
|
||||
<section class="form-section">
|
||||
<div class="section-head">
|
||||
<h3>مشخصات افزونه</h3>
|
||||
<a-button v-if="canWrite" danger type="text" @click="confirmDelete">
|
||||
<DeleteOutlined /> حذف
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<section v-if="canWrite" class="form-section renew-section">
|
||||
<h3>شارژ / تمدید جدید</h3>
|
||||
<p class="field-hint">
|
||||
با ثبت شارژ، تاریخ انقضا تمدید میشود و هشدارهای قبلی قطع میگردد تا دوباره نزدیک پایان برسد.
|
||||
</p>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="12">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تاریخ شروع دوره (شمسی)">
|
||||
<JalaliDatePicker v-model="renewForm.chargedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مدت">
|
||||
<a-select
|
||||
v-model:value="renewForm.planMonths"
|
||||
size="large"
|
||||
:options="planSelectOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ (تومان)">
|
||||
<MoneyInput v-model:value="renewForm.priceRials" placeholder="مثلاً 150,000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="یادداشت">
|
||||
<a-input v-model:value="renewForm.note" />
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام افزونه" required>
|
||||
<a-input v-model:value="form.name" size="large" placeholder="مثلاً ماژول حسابداری / پنل طلا" />
|
||||
</a-form-item>
|
||||
<a-form-item label="مشخصات">
|
||||
<a-textarea v-model:value="form.specs" :rows="2" placeholder="جزئیات فنی یا یادداشت داخلی…" />
|
||||
</a-form-item>
|
||||
<a-form-item label="توضیحات">
|
||||
<a-textarea v-model:value="form.description" :rows="2" />
|
||||
</a-form-item>
|
||||
<a-form-item label="فعال">
|
||||
<a-switch v-model:checked="form.isActive" />
|
||||
</a-form-item>
|
||||
<template v-if="isKhanegi">
|
||||
<a-form-item label="کلید تنظیمات (فروشگاه)">
|
||||
<a-select
|
||||
v-model:value="form.settingKey"
|
||||
allow-clear
|
||||
placeholder="دستی — بدون فروشگاه"
|
||||
:options="catalogKeys.map((c) => ({ value: c.settingKey, label: `${c.titleFa} (${c.settingKey})` }))"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-button type="primary" size="large" :loading="renewing" @click="renewPlugin">
|
||||
ثبت شارژ / تمدید
|
||||
</a-button>
|
||||
</a-form>
|
||||
</section>
|
||||
<a-form-item label="قطع خودکار بعد از انقضا">
|
||||
<a-switch v-model:checked="form.autoCutOnExpiry" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>تاریخچه شارژ</h3>
|
||||
<div v-if="selected.charges?.length" class="charge-list">
|
||||
<article v-for="item in selected.charges" :key="item.id" class="charge-item">
|
||||
<div>
|
||||
<strong>{{ formatMoney(item.priceRials) }}</strong>
|
||||
<span> · {{ item.planMonths }} ماهه</span>
|
||||
</div>
|
||||
<div class="charge-meta">
|
||||
از {{ formatIranDate(item.chargedAt) }} تا {{ formatIranDate(item.expiresAt) }}
|
||||
<template v-if="item.note"> · {{ item.note }}</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="hint-box">هنوز شارژی ثبت نشده است.</div>
|
||||
</section>
|
||||
<section class="form-section">
|
||||
<h3>اعتبار و قیمت (وضعیت فعلی)</h3>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="12">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تاریخ شارژ (شمسی)">
|
||||
<JalaliDatePicker v-model="form.chargedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مدت شارژ">
|
||||
<a-select
|
||||
v-model:value="form.planMonths"
|
||||
size="large"
|
||||
:options="planSelectOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="قیمت دوره (تومان)">
|
||||
<MoneyInput v-model:value="form.priceRials" placeholder="مثلاً 150,000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="شروع هشدار SMS (روز مانده به انقضا)">
|
||||
<a-input-number
|
||||
v-model:value="form.alertDaysBefore"
|
||||
:min="1"
|
||||
:max="30"
|
||||
size="large"
|
||||
style="width: 160px"
|
||||
/>
|
||||
<span class="inline-hint">پیشفرض ۳ روز — روزانه یک پیام تا تمدید</span>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<div v-if="canWrite" class="sticky-actions settings-save">
|
||||
<a-button type="primary" size="large" block :loading="saving" @click="savePlugin">
|
||||
ذخیره تنظیمات
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="detail empty-detail">
|
||||
افزونهای انتخاب نشده است.
|
||||
</div>
|
||||
<section v-if="canWrite" class="form-section renew-section">
|
||||
<h3>شارژ / تمدید جدید</h3>
|
||||
<p class="field-hint">
|
||||
با ثبت شارژ، تاریخ انقضا تمدید میشود و هشدارهای قبلی قطع میگردد تا دوباره نزدیک پایان برسد.
|
||||
</p>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="12">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تاریخ شروع دوره (شمسی)">
|
||||
<JalaliDatePicker v-model="renewForm.chargedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مدت">
|
||||
<a-select
|
||||
v-model:value="renewForm.planMonths"
|
||||
size="large"
|
||||
:options="planSelectOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ (تومان)">
|
||||
<MoneyInput v-model:value="renewForm.priceRials" placeholder="مثلاً 150,000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="یادداشت">
|
||||
<a-input v-model:value="renewForm.note" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" size="large" :loading="renewing" @click="renewPlugin">
|
||||
ثبت شارژ / تمدید
|
||||
</a-button>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>تاریخچه شارژ</h3>
|
||||
<div v-if="selected.charges?.length" class="charge-list">
|
||||
<article v-for="item in selected.charges" :key="item.id" class="charge-item">
|
||||
<div>
|
||||
<strong>{{ formatMoney(item.priceRials) }}</strong>
|
||||
<span> · {{ item.planMonths }} ماهه</span>
|
||||
</div>
|
||||
<div class="charge-meta">
|
||||
از {{ formatIranDate(item.chargedAt) }} تا {{ formatIranDate(item.expiresAt) }}
|
||||
<template v-if="item.note"> · {{ item.note }}</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="hint-box">هنوز شارژی ثبت نشده است.</div>
|
||||
</section>
|
||||
</div>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="showCreate"
|
||||
title="افزودن افزونه"
|
||||
ok-text="ثبت"
|
||||
cancel-text="انصراف"
|
||||
wrap-class-name="sa-modal-wrap"
|
||||
:confirm-loading="creating"
|
||||
@ok="createPlugin"
|
||||
>
|
||||
@@ -526,12 +596,12 @@ onMounted(loadDetail)
|
||||
<a-textarea v-model:value="createForm.specs" :rows="2" />
|
||||
</a-form-item>
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="12">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="تاریخ شارژ">
|
||||
<JalaliDatePicker v-model="createForm.chargedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="مدت">
|
||||
<a-select
|
||||
v-model:value="createForm.planMonths"
|
||||
@@ -563,37 +633,53 @@ onMounted(loadDetail)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 260px) 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-list {
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8eef5;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
font-weight: 800;
|
||||
margin-bottom: 4px;
|
||||
color: #334155;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.list-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: #17324d;
|
||||
}
|
||||
|
||||
.list-head p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.inactive-head {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.plugin-search {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.plugin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.plugin-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
appearance: none;
|
||||
border: 2px solid transparent;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
text-align: right;
|
||||
@@ -602,14 +688,24 @@ onMounted(loadDetail)
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
color: #17324d;
|
||||
min-width: 0;
|
||||
box-shadow: 0 6px 16px rgba(23, 50, 77, 0.08);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.plugin-card.active {
|
||||
border-color: #3b6fd4;
|
||||
.plugin-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 22px rgba(23, 50, 77, 0.12);
|
||||
}
|
||||
|
||||
.plugin-card strong,
|
||||
.plugin-card span {
|
||||
overflow-wrap: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-card.inactive {
|
||||
opacity: 0.65;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.plugin-card.healthy { background: linear-gradient(135deg, #dff8ea, #c3efd7); }
|
||||
@@ -618,7 +714,6 @@ onMounted(loadDetail)
|
||||
.plugin-card.unknown { background: linear-gradient(135deg, #eef2f7, #dde5ef); }
|
||||
|
||||
.empty-hint,
|
||||
.empty-detail,
|
||||
.hint-box {
|
||||
color: #94a3b8;
|
||||
padding: 14px;
|
||||
@@ -644,8 +739,10 @@ onMounted(loadDetail)
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
@@ -695,6 +792,7 @@ onMounted(loadDetail)
|
||||
border-radius: 999px;
|
||||
background: #e8f8f1;
|
||||
border: 1px solid #b7ebd4;
|
||||
min-width: 0;
|
||||
}
|
||||
.sms-toggle.off {
|
||||
background: #fff1f0;
|
||||
|
||||
@@ -7,6 +7,7 @@ import api from '@/api/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatIranDateTime } from '@/utils/datetime'
|
||||
import SettingsCatalogPanel from '@/components/SettingsCatalogPanel.vue'
|
||||
import KhanegiDepositPanel from '@/components/KhanegiDepositPanel.vue'
|
||||
import { rewriteLegacyUploadUrl } from '@/utils/rewriteLegacyUploadUrl'
|
||||
|
||||
type Tenant = {
|
||||
@@ -102,11 +103,11 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 4px">
|
||||
<a-button type="text" @click="router.push({ name: 'home' })">
|
||||
<div class="page-head">
|
||||
<a-button type="text" class="page-head-back" @click="router.push({ name: 'home' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
<p class="page-sub">{{ subtitle }} · {{ filtered.length }} مورد</p>
|
||||
</div>
|
||||
@@ -117,7 +118,6 @@ onMounted(load)
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو نام، دامنه یا slug"
|
||||
style="flex: 1; min-width: 180px"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
@@ -125,6 +125,7 @@ onMounted(load)
|
||||
<PlusOutlined /> نسخه جدید
|
||||
</a-button>
|
||||
<SettingsCatalogPanel v-if="product === 'khanegi'" :can-write="canWrite" />
|
||||
<KhanegiDepositPanel v-if="product === 'khanegi'" :can-write="canWrite" />
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
|
||||
@@ -565,6 +565,13 @@ onMounted(load)
|
||||
gap: 8px;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.head-aside {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
.field-hint {
|
||||
margin: 0 0 14px;
|
||||
color: #64748b;
|
||||
|
||||
@@ -84,11 +84,11 @@ onMounted(loadTenants)
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
|
||||
<a-button type="text" @click="router.push({ name: 'home' })">
|
||||
<div class="page-head">
|
||||
<a-button type="text" class="page-head-back" @click="router.push({ name: 'home' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">پنل سرور</h1>
|
||||
<p class="page-sub">
|
||||
هر نسخه میتواند چند سرور داشته باشد — وضعیت، شارژ و هشدار نزدیک پایان اعتبار ·
|
||||
@@ -102,7 +102,6 @@ onMounted(loadTenants)
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو نام، دامنه، slug یا نام سرور"
|
||||
style="flex: 1; min-width: 180px"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
|
||||
@@ -322,14 +322,17 @@ async function onServerAccessToggle(checked: boolean) {
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
|
||||
<a-button type="text" @click="router.push({ name: 'server-panel' })">
|
||||
<div class="page-head">
|
||||
<a-button type="text" class="page-head-back" @click="router.push({ name: 'server-panel' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">{{ tenant?.nameFa || 'سرور نسخه' }}</h1>
|
||||
<p class="page-sub">
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} · {{ tenant?.domain }}
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }}
|
||||
<template v-if="tenant?.domain">
|
||||
· <span class="page-domain">{{ tenant.domain }}</span>
|
||||
</template>
|
||||
· هر نسخه میتواند چند سرور جدا داشته باشد
|
||||
</p>
|
||||
</div>
|
||||
@@ -547,6 +550,11 @@ async function onServerAccessToggle(checked: boolean) {
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.access-kill-row > div {
|
||||
flex: 1 1 12rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.access-kill-row.blocked {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
|
||||
@@ -75,11 +75,11 @@ onMounted(loadTenants)
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
|
||||
<a-button type="text" @click="router.push({ name: 'home' })">
|
||||
<div class="page-head">
|
||||
<a-button type="text" class="page-head-back" @click="router.push({ name: 'home' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">پنل پیامکی</h1>
|
||||
<p class="page-sub">
|
||||
یک نسخه را انتخاب کنید تا کیف پول و تنظیمات SMS همان نسخه باز شود ·
|
||||
@@ -93,7 +93,6 @@ onMounted(loadTenants)
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو نام، دامنه یا slug"
|
||||
style="flex: 1; min-width: 180px"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
|
||||
@@ -404,7 +404,7 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="page-shell sms-page">
|
||||
<div class="page-head">
|
||||
<a-button type="text" @click="router.push({ name: 'sms-panel' })">
|
||||
<a-button type="text" class="page-head-back" @click="router.push({ name: 'sms-panel' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div class="page-head-text">
|
||||
|
||||
@@ -73,11 +73,11 @@ onMounted(loadTenants)
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
|
||||
<a-button type="text" @click="router.push({ name: 'home' })">
|
||||
<div class="page-head">
|
||||
<a-button type="text" class="page-head-back" @click="router.push({ name: 'home' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">پنل پشتیبانی</h1>
|
||||
<p class="page-sub">
|
||||
اشتراک پشتیبانی نسخهها — هر نسخه میتواند چند پشتیبانی داشته باشد یا هیچکدام ·
|
||||
@@ -91,7 +91,6 @@ onMounted(loadTenants)
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو نام، دامنه یا slug"
|
||||
style="flex: 1; min-width: 180px"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
|
||||
@@ -299,19 +299,24 @@ onMounted(loadDetail)
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
|
||||
<a-button type="text" @click="router.push({ name: 'support-panel' })">
|
||||
<div class="page-head">
|
||||
<a-button type="text" class="page-head-back" @click="router.push({ name: 'support-panel' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<div class="page-head-text">
|
||||
<h1 class="page-title">{{ tenant?.nameFa || 'پشتیبانیهای نسخه' }}</h1>
|
||||
<p class="page-sub">
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} · {{ tenant?.domain }}
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }}
|
||||
<template v-if="tenant?.domain">
|
||||
· <span class="page-domain">{{ tenant.domain }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<a-button v-if="canWrite" type="primary" @click="showCreate = true">
|
||||
<PlusOutlined /> پشتیبانی جدید
|
||||
</a-button>
|
||||
<div class="page-head-actions">
|
||||
<a-button v-if="canWrite" type="primary" @click="showCreate = true">
|
||||
<PlusOutlined /> پشتیبانی جدید
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TenantPanelShortcuts :tenant-id="tenantId" current="support" />
|
||||
@@ -497,6 +502,7 @@ onMounted(loadDetail)
|
||||
title="افزودن پشتیبانی"
|
||||
ok-text="ثبت"
|
||||
cancel-text="انصراف"
|
||||
wrap-class-name="sa-modal-wrap"
|
||||
:confirm-loading="creating"
|
||||
@ok="createSupport"
|
||||
>
|
||||
@@ -577,6 +583,8 @@ onMounted(loadDetail)
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
color: #17324d;
|
||||
min-width: 0;
|
||||
box-shadow: 0 6px 16px rgba(23, 50, 77, 0.08);
|
||||
}
|
||||
|
||||
.support-card.active {
|
||||
|
||||
@@ -355,7 +355,16 @@ onMounted(load)
|
||||
}
|
||||
|
||||
.page-nav-slot {
|
||||
margin-inline-start: auto;
|
||||
flex: 1 1 100%;
|
||||
margin-inline-start: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page-nav-slot {
|
||||
flex: 0 1 auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
|
||||
@@ -802,6 +802,13 @@ onMounted(load)
|
||||
gap: 8px;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.head-aside {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
|
||||
+89
-3
@@ -112,8 +112,8 @@ button, input, textarea, select,
|
||||
font-size: 1.2rem;
|
||||
color: var(--ink);
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -125,7 +125,72 @@ button, input, textarea, select,
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.7;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.page-domain {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px 10px;
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-head-back {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.page-head-text {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-head-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page-head-actions,
|
||||
.page-head .page-nav-slot,
|
||||
.head-aside {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
.page-head-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.page-head-actions > * {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page-head-actions {
|
||||
flex: 0 1 auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-modal-wrap .ant-modal {
|
||||
max-width: calc(100vw - 24px);
|
||||
top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.dash-modules {
|
||||
@@ -242,6 +307,26 @@ button, input, textarea, select,
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 14px 0 16px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar > .ant-input-affix-wrapper {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0 !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.toolbar > .ant-input-affix-wrapper {
|
||||
flex: 1 1 14rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.toolbar > .ant-btn {
|
||||
flex: 1 1 calc(50% - 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.version-list { display: grid; gap: 12px; }
|
||||
@@ -434,6 +519,7 @@ button, input, textarea, select,
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
color: #17324d;
|
||||
box-shadow: 0 6px 16px rgba(23, 50, 77, 0.08);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -460,7 +546,7 @@ button, input, textarea, select,
|
||||
|
||||
.tenant-panel-status-card strong {
|
||||
font-size: 0.88rem;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.tenant-panel-status-card .card-value,
|
||||
|
||||
Reference in New Issue
Block a user