added some feature for plugin

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