Expand SuperAdmin web for SMS, servers, plugins, admins, and UI config.
Align SMS tenant panel with Hamkar, add Jalali dates, and support hamkar/khanegi appearance editing. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
ArrowRightOutlined,
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
MessageOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type Admin = {
|
||||
id: string
|
||||
mobile: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
name?: string
|
||||
isActive: boolean
|
||||
role: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const modalOpen = ref(false)
|
||||
const editingId = ref<string | null>(null)
|
||||
const rows = ref<Admin[]>([])
|
||||
|
||||
const form = reactive({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
mobile: '',
|
||||
password: '',
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const modalTitle = computed(() => (editingId.value ? 'ویرایش مدیر' : 'افزودن مدیر'))
|
||||
|
||||
function fullName(item: Admin) {
|
||||
return item.name || `${item.firstName} ${item.lastName}`.trim()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get<Admin[]>('/admins')
|
||||
rows.value = data
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در بارگذاری مدیران')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
form.firstName = ''
|
||||
form.lastName = ''
|
||||
form.mobile = ''
|
||||
form.password = ''
|
||||
form.isActive = true
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: Admin) {
|
||||
editingId.value = item.id
|
||||
form.firstName = item.firstName
|
||||
form.lastName = item.lastName
|
||||
form.mobile = item.mobile
|
||||
form.password = ''
|
||||
form.isActive = item.isActive
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.firstName.trim() || !form.lastName.trim() || !form.mobile.trim()) {
|
||||
message.warning('نام، نام خانوادگی و موبایل لازم است')
|
||||
return
|
||||
}
|
||||
if (!editingId.value && form.password.length < 6) {
|
||||
message.warning('رمز حداقل ۶ کاراکتر باشد')
|
||||
return
|
||||
}
|
||||
if (editingId.value && form.password && form.password.length < 6) {
|
||||
message.warning('رمز حداقل ۶ کاراکتر باشد')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
const payload: Record<string, unknown> = {
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
mobile: form.mobile.trim(),
|
||||
isActive: form.isActive,
|
||||
}
|
||||
if (form.password) payload.password = form.password
|
||||
await api.patch(`/admins/${editingId.value}`, payload)
|
||||
message.success('مدیر بهروزرسانی شد')
|
||||
} else {
|
||||
await api.post('/admins', {
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
mobile: form.mobile.trim(),
|
||||
password: form.password,
|
||||
})
|
||||
message.success('مدیر اضافه شد')
|
||||
}
|
||||
modalOpen.value = false
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(item: Admin) {
|
||||
if (item.id === auth.user?.id) {
|
||||
message.warning('نمیتوانید خودتان را حذف کنید')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: `حذف ${fullName(item)}؟`,
|
||||
content: 'این عمل قابل بازگشت نیست.',
|
||||
okText: 'حذف',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.delete(`/admins/${item.id}`)
|
||||
message.success('حذف شد')
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'حذف ناموفق')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function testSms(item: Admin) {
|
||||
try {
|
||||
await api.post(`/admins/${item.id}/test-sms`, {})
|
||||
message.success(`پیامک تست به ${item.mobile} ارسال شد`)
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ارسال ناموفق')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<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' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<h1 class="page-title">مدیران</h1>
|
||||
<p class="page-sub">
|
||||
مدیریت کاربران سوپرادمین — ورود با موبایل و رمز. هشدارها به همین شمارهها پیامک میشود.
|
||||
</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="openCreate">
|
||||
<PlusOutlined /> افزودن مدیر
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="rows.length" class="admin-list">
|
||||
<article v-for="item in rows" :key="item.id" class="admin-card">
|
||||
<div>
|
||||
<h3>{{ fullName(item) }}</h3>
|
||||
<div class="admin-mobile" dir="ltr">{{ item.mobile }}</div>
|
||||
<div style="margin-top: 8px">
|
||||
<a-tag :color="item.isActive ? 'success' : 'default'">
|
||||
{{ item.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</a-tag>
|
||||
<a-tag>{{ item.role }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-actions">
|
||||
<a-button @click="testSms(item)">
|
||||
<MessageOutlined /> تست SMS
|
||||
</a-button>
|
||||
<a-button type="primary" ghost @click="openEdit(item)">
|
||||
<EditOutlined /> ویرایش
|
||||
</a-button>
|
||||
<a-button danger :disabled="item.id === auth.user?.id" @click="confirmDelete(item)">
|
||||
<DeleteOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="hint-box">هنوز مدیری ثبت نشده است.</div>
|
||||
</a-spin>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalOpen"
|
||||
:title="modalTitle"
|
||||
:confirm-loading="saving"
|
||||
ok-text="ذخیره"
|
||||
cancel-text="انصراف"
|
||||
@ok="save"
|
||||
>
|
||||
<a-form layout="vertical" style="margin-top: 12px">
|
||||
<a-form-item label="نام" required>
|
||||
<a-input v-model:value="form.firstName" size="large" />
|
||||
</a-form-item>
|
||||
<a-form-item label="نام خانوادگی" required>
|
||||
<a-input v-model:value="form.lastName" size="large" />
|
||||
</a-form-item>
|
||||
<a-form-item label="موبایل" required>
|
||||
<a-input v-model:value="form.mobile" size="large" dir="ltr" placeholder="09xxxxxxxxx" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="editingId ? 'رمز جدید (اختیاری)' : 'رمز عبور'" :required="!editingId">
|
||||
<a-input-password v-model:value="form.password" size="large" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="editingId" label="فعال">
|
||||
<a-switch v-model:checked="form.isActive" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8eef5;
|
||||
}
|
||||
|
||||
.admin-card h3 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.admin-mobile {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import api from '@/api/http'
|
||||
import { formatIranDateTime } from '@/utils/datetime'
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<any[]>([])
|
||||
@@ -29,11 +30,11 @@ onMounted(load)
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="rows.length" class="audit-list" style="margin-top: 14px">
|
||||
<article v-for="item in rows" :key="item.id" class="audit-item">
|
||||
<div class="when">{{ new Date(item.createdAt).toLocaleString('fa-IR') }}</div>
|
||||
<div class="when">{{ formatIranDateTime(item.createdAt) }}</div>
|
||||
<div class="action">{{ item.action }}</div>
|
||||
<div style="margin-top: 6px; color: #64748b; font-size: 0.85rem">
|
||||
نسخه: {{ item.tenant?.nameFa || item.tenantId || '—' }}
|
||||
· کاربر: {{ item.user?.name || '—' }}
|
||||
· کاربر: {{ item.user?.name || [item.user?.firstName, item.user?.lastName].filter(Boolean).join(' ') || '—' }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
import {
|
||||
createDefaultHamkarUiConfig,
|
||||
HAMKAR_ORDER_UI_OPTIONS,
|
||||
HAMKAR_PRICE_FONT_SIZE_OPTIONS,
|
||||
HAMKAR_PRICING_BOX_THEME_OPTIONS,
|
||||
hamkarUiToApiPayload,
|
||||
normalizeHamkarUiConfig,
|
||||
type HamkarUiConfig,
|
||||
} from '@/config/defaultHamkarUiConfig'
|
||||
import { resolveHamkarEffectiveTheme } from '@/config/hamkarEffectiveTheme'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const tenantName = ref('')
|
||||
const domain = ref('')
|
||||
|
||||
const config = reactive<HamkarUiConfig>(createDefaultHamkarUiConfig())
|
||||
const settingsManaged = computed({
|
||||
get: () => config.settingsManaged,
|
||||
set: (v: boolean) => {
|
||||
config.settingsManaged = v
|
||||
},
|
||||
})
|
||||
|
||||
const themeOptions = computed(() =>
|
||||
HAMKAR_PRICING_BOX_THEME_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
|
||||
)
|
||||
const orderOptions = computed(() =>
|
||||
HAMKAR_ORDER_UI_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
|
||||
)
|
||||
const fontOptions = computed(() =>
|
||||
HAMKAR_PRICE_FONT_SIZE_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
|
||||
)
|
||||
|
||||
const NAV_ITEMS = ['معامله', 'دارایی', 'سفارشات', 'گزارشدهی', 'مشتریان']
|
||||
|
||||
function swatchHex(value: string, fallback = '#cbd5e1') {
|
||||
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value.trim()) ? value.trim() : fallback
|
||||
}
|
||||
|
||||
function textOn(bg: string) {
|
||||
const hex = swatchHex(bg, '#ffffff').replace('#', '')
|
||||
const full =
|
||||
hex.length === 3
|
||||
? hex
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
: hex
|
||||
const n = parseInt(full, 16)
|
||||
const r = (n >> 16) & 255
|
||||
const g = (n >> 8) & 255
|
||||
const b = n & 255
|
||||
return (r * 299 + g * 587 + b * 114) / 1000 > 128 ? '#0f172a' : '#ffffff'
|
||||
}
|
||||
|
||||
/** Live palette driving the preview (= what save will apply via dynamicMode) */
|
||||
const palette = computed(() => ({
|
||||
header: swatchHex(config.brand.primaryColor, '#ffffff'),
|
||||
accent: swatchHex(config.brand.secondaryColor, '#2563EB'),
|
||||
page: swatchHex(config.brand.backgroundColor, '#fafafa'),
|
||||
}))
|
||||
|
||||
const previewChrome = computed(() => {
|
||||
const p = palette.value
|
||||
const onHeader = textOn(p.header)
|
||||
return {
|
||||
shell: {
|
||||
background: p.page,
|
||||
borderRadius: '18px',
|
||||
border: '1px solid #e2e8f0',
|
||||
overflow: 'hidden' as const,
|
||||
},
|
||||
header: {
|
||||
background: p.header,
|
||||
color: onHeader,
|
||||
borderBottom: onHeader === '#ffffff' ? '1px solid rgba(255,255,255,0.12)' : '1px solid #e2e8f0',
|
||||
},
|
||||
accent: p.accent,
|
||||
muted: onHeader === '#ffffff' ? 'rgba(255,255,255,0.72)' : '#616161',
|
||||
}
|
||||
})
|
||||
|
||||
function seedEditableColors(normalized: HamkarUiConfig) {
|
||||
const effective = resolveHamkarEffectiveTheme({
|
||||
registryMode: normalized.registryMode,
|
||||
primaryColor: normalized.brand.primaryColor,
|
||||
secondaryColor: normalized.brand.secondaryColor,
|
||||
backgroundColor: normalized.brand.backgroundColor,
|
||||
})
|
||||
// Named CSS modes ignore registry colors — seed pickers with what the app actually paints
|
||||
if (!effective.isDynamic) {
|
||||
normalized.brand.primaryColor = effective.headerBg
|
||||
normalized.brand.secondaryColor = effective.accent
|
||||
normalized.brand.backgroundColor = effective.pageBg
|
||||
} else {
|
||||
if (!normalized.brand.primaryColor) normalized.brand.primaryColor = effective.headerBg
|
||||
if (!normalized.brand.secondaryColor) normalized.brand.secondaryColor = effective.accent
|
||||
if (!normalized.brand.backgroundColor) normalized.brand.backgroundColor = effective.pageBg
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/tenants/${route.params.id}`)
|
||||
if (data.product !== 'hamkar') {
|
||||
message.warning('این صفحه فقط برای نسخههای همکار است')
|
||||
router.replace({ name: 'version-ui', params: { id: String(route.params.id) } })
|
||||
return
|
||||
}
|
||||
tenantName.value = data.nameFa || ''
|
||||
domain.value = data.domain || ''
|
||||
|
||||
const rawUi =
|
||||
data.appUiConfig && data.appUiConfig.kind === 'hamkar'
|
||||
? data.appUiConfig
|
||||
: {
|
||||
kind: 'hamkar',
|
||||
settingsManaged: false,
|
||||
registryMode:
|
||||
data.appUiConfig?.registryMode ||
|
||||
data.notes?.match(/registryMode:([^\s|]+)/)?.[1] ||
|
||||
'dynamicMode',
|
||||
brand: {
|
||||
primaryColor: data.primaryColor,
|
||||
secondaryColor: data.secondaryColor,
|
||||
backgroundColor: data.backgroundColor,
|
||||
},
|
||||
settings: {},
|
||||
}
|
||||
|
||||
const normalized = normalizeHamkarUiConfig(rawUi)
|
||||
if (!normalized.brand.primaryColor && data.primaryColor) {
|
||||
normalized.brand.primaryColor = String(data.primaryColor)
|
||||
}
|
||||
if (!normalized.brand.secondaryColor && data.secondaryColor) {
|
||||
normalized.brand.secondaryColor = String(data.secondaryColor)
|
||||
}
|
||||
if (!normalized.brand.backgroundColor && data.backgroundColor) {
|
||||
normalized.brand.backgroundColor = String(data.backgroundColor)
|
||||
}
|
||||
seedEditableColors(normalized)
|
||||
Object.assign(config, normalized)
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در بارگذاری ظاهر همکار')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
// Colors only paint the app in dynamicMode (Hamkar apply-theme)
|
||||
config.registryMode = 'dynamicMode'
|
||||
const payload = hamkarUiToApiPayload(config)
|
||||
await api.patch(`/tenants/${route.params.id}`, {
|
||||
appUiConfig: payload,
|
||||
primaryColor: payload.brand.primaryColor,
|
||||
secondaryColor: payload.brand.secondaryColor,
|
||||
backgroundColor: payload.brand.backgroundColor,
|
||||
})
|
||||
Object.assign(config, normalizeHamkarUiConfig(payload))
|
||||
message.success('ظاهر همکار ذخیره شد')
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<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' } })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<h1 class="page-title">ظاهر اپ همکار</h1>
|
||||
<p class="page-sub">
|
||||
{{ tenantName || 'نسخه' }}
|
||||
<template v-if="domain"> · {{ domain }}</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="layout">
|
||||
<section class="form-section preview">
|
||||
<h3>پیشنمایش</h3>
|
||||
<div class="app-preview" :style="previewChrome.shell">
|
||||
<div class="app-preview-header" :style="previewChrome.header">
|
||||
<div
|
||||
v-for="(item, i) in NAV_ITEMS"
|
||||
:key="item"
|
||||
class="app-preview-nav"
|
||||
:style="{ color: i === 0 ? previewChrome.accent : previewChrome.muted }"
|
||||
>
|
||||
<span
|
||||
class="app-preview-dot"
|
||||
:style="{ background: i === 0 ? previewChrome.accent : previewChrome.muted }"
|
||||
/>
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-preview-body">
|
||||
<div class="app-preview-card">
|
||||
<div class="app-preview-card-title">خوش آمدگویی</div>
|
||||
<div class="app-preview-card-line" />
|
||||
<div class="app-preview-card-line short" />
|
||||
</div>
|
||||
<div class="app-preview-price" :style="{ borderColor: palette.accent }">
|
||||
<span>نقد فردا</span>
|
||||
<strong :style="{ color: palette.accent }">۸۱٬۴۵۰٬۰۰۰</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>رنگها</h3>
|
||||
<div class="palette-grid">
|
||||
<label class="palette-card">
|
||||
<span class="palette-title">هدر</span>
|
||||
<span class="palette-swatch" :style="{ background: palette.header }">
|
||||
<input
|
||||
:value="palette.header"
|
||||
type="color"
|
||||
@input="config.brand.primaryColor = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</span>
|
||||
<a-input
|
||||
v-model:value="config.brand.primaryColor"
|
||||
dir="ltr"
|
||||
size="large"
|
||||
class="palette-hex"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="palette-card">
|
||||
<span class="palette-title">اکسنت</span>
|
||||
<span class="palette-swatch" :style="{ background: palette.accent }">
|
||||
<input
|
||||
:value="palette.accent"
|
||||
type="color"
|
||||
@input="config.brand.secondaryColor = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</span>
|
||||
<a-input
|
||||
v-model:value="config.brand.secondaryColor"
|
||||
dir="ltr"
|
||||
size="large"
|
||||
class="palette-hex"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="palette-card">
|
||||
<span class="palette-title">پسزمینه</span>
|
||||
<span class="palette-swatch" :style="{ background: palette.page }">
|
||||
<input
|
||||
:value="palette.page"
|
||||
type="color"
|
||||
@input="config.brand.backgroundColor = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</span>
|
||||
<a-input
|
||||
v-model:value="config.brand.backgroundColor"
|
||||
dir="ltr"
|
||||
size="large"
|
||||
class="palette-hex"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>تنظیمات UI نسخه</h3>
|
||||
<a-form-item label="کنترل از سوپرادمین">
|
||||
<a-switch v-model:checked="settingsManaged" />
|
||||
</a-form-item>
|
||||
<a-form layout="vertical" :disabled="!config.settingsManaged">
|
||||
<a-row :gutter="12">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تم جعبه قیمت">
|
||||
<a-select
|
||||
v-model:value="config.settings.pricing_box_theme"
|
||||
size="large"
|
||||
:options="themeOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="سایز فونت قیمت">
|
||||
<a-select
|
||||
v-model:value="config.settings.price_font_size"
|
||||
size="large"
|
||||
:options="fontOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نمایش سفارشات">
|
||||
<a-select
|
||||
v-model:value="config.settings.orderUI"
|
||||
size="large"
|
||||
:options="orderOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<div class="sticky-actions">
|
||||
<a-button type="primary" size="large" block :loading="saving" @click="save">
|
||||
ذخیره ظاهر همکار
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.app-preview {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.app-preview-header {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 8px;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.app-preview-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-preview-dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 6px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.app-preview-body {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-preview-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.app-preview-card-title {
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.app-preview-card-line {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.app-preview-card-line.short {
|
||||
width: 55%;
|
||||
}
|
||||
|
||||
.app-preview-price {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-right-width: 4px;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.palette-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.palette-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.palette-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.palette-title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.palette-swatch {
|
||||
position: relative;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.palette-swatch input[type='color'] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.palette-hex {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
+142
-9
@@ -4,32 +4,58 @@ import { useRouter } from 'vue-router'
|
||||
import {
|
||||
MessageOutlined,
|
||||
CloudServerOutlined,
|
||||
AppstoreAddOutlined,
|
||||
TeamOutlined,
|
||||
HomeOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import api from '@/api/http'
|
||||
|
||||
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
type SmsSummary = {
|
||||
balanceRials: number | null
|
||||
balanceLabel: string
|
||||
balanceLevel: BalanceLevel
|
||||
}
|
||||
|
||||
type ServerSummary = {
|
||||
expiryLevel: ExpiryLevel
|
||||
expiryLabel: string
|
||||
isDedicated?: boolean
|
||||
}
|
||||
|
||||
type Tenant = {
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
slug?: string
|
||||
nameFa?: string
|
||||
sms?: SmsSummary
|
||||
server?: ServerSummary
|
||||
}
|
||||
|
||||
type ExpiringPlugin = {
|
||||
id: string
|
||||
tenantId: string
|
||||
name: string
|
||||
expiryLevel: ExpiryLevel
|
||||
expiryLabel: string
|
||||
tenant: {
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
nameFa: string
|
||||
}
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const tenants = ref<Tenant[]>([])
|
||||
const smsTenants = ref<Tenant[]>([])
|
||||
const expiringServers = ref<Tenant[]>([])
|
||||
const expiringPlugins = ref<ExpiringPlugin[]>([])
|
||||
|
||||
const hamkarCount = computed(() => tenants.value.filter((t) => t.product === 'hamkar').length)
|
||||
const khanegiCount = computed(() => tenants.value.filter((t) => t.product === 'khanegi').length)
|
||||
@@ -37,13 +63,13 @@ const lowestSmsTenants = computed(() => smsTenants.value.slice(0, 5))
|
||||
|
||||
const modules = computed(() => [
|
||||
{
|
||||
key: 'server',
|
||||
className: 'server',
|
||||
title: 'پنل سرور',
|
||||
desc: 'شارژ، تمدید و پیگیری وضعیت سرورهای نسخههای فروشی',
|
||||
meta: 'جدید',
|
||||
icon: CloudServerOutlined,
|
||||
to: { name: 'server-panel' as const },
|
||||
key: 'admins',
|
||||
className: 'admins',
|
||||
title: 'مدیران',
|
||||
desc: 'افزودن و ویرایش مدیران؛ ورود با موبایل و دریافت هشدار SMS',
|
||||
meta: 'دسترسی',
|
||||
icon: UserOutlined,
|
||||
to: { name: 'admins' as const },
|
||||
},
|
||||
{
|
||||
key: 'hamkar',
|
||||
@@ -68,12 +94,16 @@ const modules = computed(() => [
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [tenantsRes, lowestRes] = await Promise.all([
|
||||
const [tenantsRes, lowestRes, expiringRes, pluginExpiringRes] = await Promise.all([
|
||||
api.get('/tenants'),
|
||||
api.get('/sms/lowest', { params: { limit: 5 } }),
|
||||
api.get('/servers/expiring', { params: { limit: 5 } }),
|
||||
api.get('/plugins/expiring', { params: { limit: 5 } }),
|
||||
])
|
||||
tenants.value = tenantsRes.data
|
||||
smsTenants.value = lowestRes.data
|
||||
expiringServers.value = expiringRes.data
|
||||
expiringPlugins.value = pluginExpiringRes.data
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در دریافت آمار')
|
||||
} finally {
|
||||
@@ -89,6 +119,22 @@ function openSmsPanel(tenantId?: string) {
|
||||
router.push({ name: 'sms-panel' })
|
||||
}
|
||||
|
||||
function openServerPanel(tenantId?: string) {
|
||||
if (tenantId) {
|
||||
router.push({ name: 'server-tenant', params: { tenantId } })
|
||||
return
|
||||
}
|
||||
router.push({ name: 'server-panel' })
|
||||
}
|
||||
|
||||
function openPluginPanel(tenantId?: string) {
|
||||
if (tenantId) {
|
||||
router.push({ name: 'plugin-tenant', params: { tenantId } })
|
||||
return
|
||||
}
|
||||
router.push({ name: 'plugin-panel' })
|
||||
}
|
||||
|
||||
function smsTone(level?: BalanceLevel) {
|
||||
switch (level) {
|
||||
case 'healthy':
|
||||
@@ -102,6 +148,21 @@ function smsTone(level?: BalanceLevel) {
|
||||
}
|
||||
}
|
||||
|
||||
function serverTone(level?: ExpiryLevel) {
|
||||
switch (level) {
|
||||
case 'dedicated':
|
||||
return 'dedicated'
|
||||
case 'healthy':
|
||||
return 'healthy'
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
case 'critical':
|
||||
return 'critical'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -110,7 +171,7 @@ onMounted(load)
|
||||
<div class="login-kicker">داشبورد کنترل</div>
|
||||
<h1 class="page-title">سلام، خوش آمدید</h1>
|
||||
<p class="page-sub">
|
||||
از اینجا به پنل پیامکی، پنل سرور و نسخههای همکار / خانگی دسترسی دارید.
|
||||
از اینجا به پنل پیامکی، سرور، افزونه، مدیران و نسخههای همکار / خانگی دسترسی دارید.
|
||||
</p>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
@@ -149,6 +210,74 @@ onMounted(load)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="module-card server sms-dashboard-card" @click="openServerPanel()">
|
||||
<div>
|
||||
<div class="icon-wrap">
|
||||
<CloudServerOutlined />
|
||||
</div>
|
||||
<h3>پنل سرور</h3>
|
||||
<p>نسخههایی که اعتبار سرورشان رو به پایان است</p>
|
||||
</div>
|
||||
<div class="sms-mini-grid">
|
||||
<button
|
||||
v-for="tenant in expiringServers"
|
||||
:key="tenant.id"
|
||||
type="button"
|
||||
class="sms-mini-card"
|
||||
:class="serverTone(tenant.server?.expiryLevel)"
|
||||
@click.stop="openServerPanel(tenant.id)"
|
||||
>
|
||||
<strong>{{ tenant.nameFa }}</strong>
|
||||
<span>{{ tenant.product === 'hamkar' ? 'همکار' : 'خانگی' }}</span>
|
||||
<small>{{ tenant.server?.expiryLabel || 'نامشخص' }}</small>
|
||||
</button>
|
||||
<div v-if="!expiringServers.length" class="sms-mini-empty">
|
||||
سرور نزدیکانقضا یا بدون تاریخ ثبتشده نیست
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="progress"><span /></div>
|
||||
<div class="module-meta">
|
||||
<span>{{ expiringServers.length }} مورد پیگیری</span>
|
||||
<span>ورود ←</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="module-card plugin sms-dashboard-card" @click="openPluginPanel()">
|
||||
<div>
|
||||
<div class="icon-wrap">
|
||||
<AppstoreAddOutlined />
|
||||
</div>
|
||||
<h3>پنل افزونه</h3>
|
||||
<p>افزونههایی که اعتبارشان رو به پایان است (هر نسخه چند افزونه)</p>
|
||||
</div>
|
||||
<div class="sms-mini-grid">
|
||||
<button
|
||||
v-for="item in expiringPlugins"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="sms-mini-card"
|
||||
:class="serverTone(item.expiryLevel)"
|
||||
@click.stop="openPluginPanel(item.tenantId)"
|
||||
>
|
||||
<strong>{{ item.tenant?.nameFa }}</strong>
|
||||
<span>{{ item.name }}</span>
|
||||
<small>{{ item.expiryLabel || 'نامشخص' }}</small>
|
||||
</button>
|
||||
<div v-if="!expiringPlugins.length" class="sms-mini-empty">
|
||||
افزونه نزدیکانقضا یا بدون تاریخ ثبتشده نیست
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="progress"><span /></div>
|
||||
<div class="module-meta">
|
||||
<span>{{ expiringPlugins.length }} مورد پیگیری</span>
|
||||
<span>ورود ←</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="item in modules"
|
||||
:key="item.key"
|
||||
@@ -223,6 +352,10 @@ onMounted(load)
|
||||
background: linear-gradient(135deg, #ffd8d8, #ffb9b9);
|
||||
}
|
||||
|
||||
.sms-mini-card.dedicated {
|
||||
background: linear-gradient(135deg, #e8eef8, #d6e0f5);
|
||||
}
|
||||
|
||||
.sms-mini-card.unknown {
|
||||
background: linear-gradient(135deg, #eef2f7, #dde5ef);
|
||||
}
|
||||
|
||||
+14
-11
@@ -9,14 +9,14 @@ const router = useRouter()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
email: 'admin@abshode.local',
|
||||
password: 'Admin@12345',
|
||||
mobile: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(form.email, form.password)
|
||||
await auth.login(form.mobile, form.password)
|
||||
message.success('ورود موفق')
|
||||
await router.push({ name: 'home' })
|
||||
} catch (e: any) {
|
||||
@@ -36,18 +36,21 @@ async function onSubmit() {
|
||||
<div class="login-kicker">Super Admin · کنترل مرکزی</div>
|
||||
<div class="brand-mark" style="font-size: 26px">آبشده</div>
|
||||
<div style="color: #8b95a8; margin: 8px 0 22px; line-height: 1.7">
|
||||
ورود به داشبورد مدیریت پیامک، سرور و نسخههای همکار / خانگی
|
||||
ورود با شماره موبایل و رمز مدیران
|
||||
</div>
|
||||
<a-form :model="form" layout="vertical" @finish="onSubmit">
|
||||
<a-form-item
|
||||
label="ایمیل"
|
||||
name="email"
|
||||
:rules="[
|
||||
{ required: true, message: 'ایمیل لازم است' },
|
||||
{ type: 'string', min: 3, message: 'ایمیل نامعتبر است' },
|
||||
]"
|
||||
label="موبایل"
|
||||
name="mobile"
|
||||
:rules="[{ required: true, message: 'موبایل لازم است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.email" size="large" autocomplete="username" />
|
||||
<a-input
|
||||
v-model:value="form.mobile"
|
||||
size="large"
|
||||
dir="ltr"
|
||||
placeholder="09xxxxxxxxx"
|
||||
autocomplete="username"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="رمز عبور"
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, AppstoreAddOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
|
||||
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
|
||||
|
||||
type TenantRow = {
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
nameFa: string
|
||||
domain: string
|
||||
pluginCount: number
|
||||
summary: {
|
||||
expiryLevel: ExpiryLevel
|
||||
expiryLabel: string
|
||||
}
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const loadingList = ref(false)
|
||||
const tenants = ref<TenantRow[]>([])
|
||||
|
||||
const groupedTenants = computed(() => ({
|
||||
hamkar: tenants.value.filter((t) => t.product === 'hamkar'),
|
||||
khanegi: tenants.value.filter((t) => t.product === 'khanegi'),
|
||||
}))
|
||||
|
||||
function toneClass(level?: ExpiryLevel) {
|
||||
return level && level !== 'none' ? level : 'unknown'
|
||||
}
|
||||
|
||||
function openTenant(id: string) {
|
||||
router.push({ name: 'plugin-tenant', params: { tenantId: id } })
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
loadingList.value = true
|
||||
try {
|
||||
const { data } = await api.get('/plugins/tenants')
|
||||
tenants.value = data
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در دریافت افزونهها')
|
||||
} finally {
|
||||
loadingList.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTenants)
|
||||
</script>
|
||||
|
||||
<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' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<h1 class="page-title">پنل افزونه</h1>
|
||||
<p class="page-sub">
|
||||
اشتراک افزونههای نسخهها — هر نسخه میتواند چند افزونه داشته باشد یا هیچکدام
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span class="chip healthy">اعتبار کافی</span>
|
||||
<span class="chip warning">نزدیک هشدار</span>
|
||||
<span class="chip critical">۳ روز یا کمتر / منقضی</span>
|
||||
<span class="chip unknown">بدون افزونه یا بدون تاریخ</span>
|
||||
</div>
|
||||
|
||||
<section class="sms-selector">
|
||||
<div class="sms-side-head"><AppstoreAddOutlined /> نسخهها</div>
|
||||
<a-spin :spinning="loadingList">
|
||||
<div class="tenant-groups">
|
||||
<section class="tenant-group">
|
||||
<div class="tenant-group-head">
|
||||
<h3>همکار</h3>
|
||||
<span>{{ groupedTenants.hamkar.length }} نسخه</span>
|
||||
</div>
|
||||
<div class="tenant-grid">
|
||||
<button
|
||||
v-for="t in groupedTenants.hamkar"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="tenant-card"
|
||||
:class="toneClass(t.summary?.expiryLevel)"
|
||||
@click="openTenant(t.id)"
|
||||
>
|
||||
<strong>{{ t.nameFa }}</strong>
|
||||
<span>{{ t.summary?.expiryLabel }}</span>
|
||||
<small>{{ t.pluginCount }} افزونه</small>
|
||||
</button>
|
||||
<div v-if="!groupedTenants.hamkar.length" class="empty-hint">نسخهای نیست</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tenant-group">
|
||||
<div class="tenant-group-head">
|
||||
<h3>خانگی</h3>
|
||||
<span>{{ groupedTenants.khanegi.length }} نسخه</span>
|
||||
</div>
|
||||
<div class="tenant-grid">
|
||||
<button
|
||||
v-for="t in groupedTenants.khanegi"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="tenant-card"
|
||||
:class="toneClass(t.summary?.expiryLevel)"
|
||||
@click="openTenant(t.id)"
|
||||
>
|
||||
<strong>{{ t.nameFa }}</strong>
|
||||
<span>{{ t.summary?.expiryLabel }}</span>
|
||||
<small>{{ t.pluginCount }} افزونه</small>
|
||||
</button>
|
||||
<div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخهای نیست</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</a-spin>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chip.healthy { background: #dff8ea; color: #0f7a45; }
|
||||
.chip.warning { background: #fff4c7; color: #9a6b00; }
|
||||
.chip.critical { background: #ffd8d8; color: #b42318; }
|
||||
.chip.unknown { background: #eef2f7; color: #64748b; }
|
||||
|
||||
.sms-selector {
|
||||
border-radius: 22px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8eef5;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sms-side-head {
|
||||
font-weight: 800;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tenant-groups { display: grid; gap: 18px; }
|
||||
|
||||
.tenant-group-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tenant-group-head h3 { margin: 0; font-size: 1rem; }
|
||||
.tenant-group-head span { color: #94a3b8; font-size: 0.85rem; }
|
||||
|
||||
.tenant-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tenant-card {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: #17324d;
|
||||
}
|
||||
|
||||
.tenant-card strong { font-size: 0.95rem; }
|
||||
.tenant-card span,
|
||||
.tenant-card small { opacity: 0.92; font-size: 0.82rem; }
|
||||
|
||||
.tenant-card.healthy { background: linear-gradient(135deg, #dff8ea, #c3efd7); }
|
||||
.tenant-card.warning { background: linear-gradient(135deg, #fff4c7, #ffe39a); }
|
||||
.tenant-card.critical { background: linear-gradient(135deg, #ffd8d8, #ffb9b9); }
|
||||
.tenant-card.unknown { background: linear-gradient(135deg, #eef2f7, #dde5ef); }
|
||||
|
||||
.empty-hint {
|
||||
grid-column: 1 / -1;
|
||||
color: #94a3b8;
|
||||
padding: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,619 @@
|
||||
<script setup lang="ts">
|
||||
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 api from '@/api/http'
|
||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
|
||||
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
|
||||
|
||||
type PluginItem = {
|
||||
id: string
|
||||
name: string
|
||||
specs: string | null
|
||||
description: string | null
|
||||
priceRials: number | null
|
||||
chargedAt: string | null
|
||||
planMonths: number | null
|
||||
expiresAt: string | null
|
||||
alertDaysBefore: number
|
||||
isActive: boolean
|
||||
summary: {
|
||||
expiryLevel: ExpiryLevel
|
||||
expiryLabel: string
|
||||
planLabel: string | null
|
||||
expiresAt: string | null
|
||||
}
|
||||
charges: any[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const renewing = ref(false)
|
||||
const creating = ref(false)
|
||||
const selectedId = ref<string | null>(null)
|
||||
const showCreate = ref(false)
|
||||
|
||||
const tenant = ref<{
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
slug: string
|
||||
nameFa: string
|
||||
domain: string
|
||||
} | null>(null)
|
||||
const plugins = ref<PluginItem[]>([])
|
||||
const planOptions = ref<{ months: number; label: string }[]>([])
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
specs: '',
|
||||
description: '',
|
||||
priceRials: 0 as number,
|
||||
chargedAt: null as string | null,
|
||||
planMonths: 1 as number,
|
||||
alertDaysBefore: 3,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
specs: '',
|
||||
description: '',
|
||||
priceRials: 0 as number,
|
||||
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
|
||||
planMonths: 1,
|
||||
alertDaysBefore: 3,
|
||||
})
|
||||
|
||||
const renewForm = reactive({
|
||||
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
|
||||
planMonths: 1,
|
||||
priceRials: 0 as number,
|
||||
note: '',
|
||||
})
|
||||
|
||||
const tenantId = computed(() => String(route.params.tenantId || ''))
|
||||
|
||||
const selected = computed(() => plugins.value.find((p) => p.id === selectedId.value) || null)
|
||||
|
||||
const planSelectOptions = computed(() =>
|
||||
(planOptions.value.length
|
||||
? planOptions.value
|
||||
: [
|
||||
{ months: 1, label: 'یکماهه' },
|
||||
{ months: 3, label: 'سهماهه' },
|
||||
{ months: 6, label: 'ششماهه' },
|
||||
{ months: 12, label: 'یکساله' },
|
||||
]
|
||||
).map((p) => ({ value: p.months, label: p.label })),
|
||||
)
|
||||
|
||||
function toneClass(level?: ExpiryLevel) {
|
||||
return level && level !== 'none' ? level : 'unknown'
|
||||
}
|
||||
|
||||
function formatMoney(value: number | null | undefined) {
|
||||
if (value == null) return '—'
|
||||
return `${new Intl.NumberFormat('fa-IR').format(value)} ریال`
|
||||
}
|
||||
|
||||
function fillForm(plugin: PluginItem) {
|
||||
Object.assign(form, {
|
||||
name: plugin.name || '',
|
||||
specs: plugin.specs || '',
|
||||
description: plugin.description || '',
|
||||
priceRials: plugin.priceRials ?? 0,
|
||||
chargedAt: plugin.chargedAt || null,
|
||||
planMonths: plugin.planMonths || 1,
|
||||
alertDaysBefore: plugin.alertDaysBefore ?? 3,
|
||||
isActive: plugin.isActive !== false,
|
||||
})
|
||||
renewForm.planMonths = plugin.planMonths || 1
|
||||
renewForm.priceRials = plugin.priceRials ?? 0
|
||||
renewForm.chargedAt = iranDateInputToIso(todayIranDateInput())
|
||||
renewForm.note = ''
|
||||
}
|
||||
|
||||
function selectPlugin(id: string) {
|
||||
selectedId.value = id
|
||||
const plugin = plugins.value.find((p) => p.id === id)
|
||||
if (plugin) fillForm(plugin)
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!tenantId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/plugins/tenants/${tenantId.value}`)
|
||||
tenant.value = data.tenant
|
||||
plugins.value = data.plugins || []
|
||||
planOptions.value = data.planOptions || []
|
||||
|
||||
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 (plugin) fillForm(plugin)
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در دریافت افزونهها')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlugin() {
|
||||
if (!createForm.name.trim()) {
|
||||
message.warning('نام افزونه لازم است')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const { data } = await api.post(`/plugins/tenants/${tenantId.value}`, {
|
||||
name: createForm.name.trim(),
|
||||
specs: createForm.specs || undefined,
|
||||
description: createForm.description || undefined,
|
||||
priceRials: Number(createForm.priceRials) || 0,
|
||||
chargedAt: createForm.chargedAt || undefined,
|
||||
planMonths: Number(createForm.planMonths),
|
||||
alertDaysBefore: Number(createForm.alertDaysBefore) || 3,
|
||||
})
|
||||
message.success('افزونه اضافه شد')
|
||||
showCreate.value = false
|
||||
createForm.name = ''
|
||||
createForm.specs = ''
|
||||
createForm.description = ''
|
||||
createForm.priceRials = 0
|
||||
createForm.chargedAt = iranDateInputToIso(todayIranDateInput())
|
||||
createForm.planMonths = 1
|
||||
createForm.alertDaysBefore = 3
|
||||
await loadDetail()
|
||||
if (data?.plugin?.id) selectPlugin(data.plugin.id)
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ثبت ناموفق')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlugin() {
|
||||
if (!selectedId.value) return
|
||||
if (!form.name.trim()) {
|
||||
message.warning('نام افزونه لازم است')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await api.patch(`/plugins/items/${selectedId.value}`, {
|
||||
name: form.name.trim(),
|
||||
specs: form.specs || null,
|
||||
description: form.description || null,
|
||||
priceRials: Number(form.priceRials) || 0,
|
||||
chargedAt: form.chargedAt,
|
||||
planMonths: Number(form.planMonths),
|
||||
alertDaysBefore: Number(form.alertDaysBefore) || 3,
|
||||
isActive: form.isActive,
|
||||
})
|
||||
message.success('ذخیره شد')
|
||||
await loadDetail()
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function renewPlugin() {
|
||||
if (!selectedId.value) return
|
||||
if (!renewForm.chargedAt) {
|
||||
message.warning('تاریخ شارژ لازم است')
|
||||
return
|
||||
}
|
||||
renewing.value = true
|
||||
try {
|
||||
await api.post(`/plugins/items/${selectedId.value}/renew`, {
|
||||
chargedAt: renewForm.chargedAt,
|
||||
planMonths: Number(renewForm.planMonths),
|
||||
priceRials: Number(renewForm.priceRials),
|
||||
note: renewForm.note || undefined,
|
||||
})
|
||||
message.success('افزونه شارژ/تمدید شد')
|
||||
renewForm.note = ''
|
||||
await loadDetail()
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'شارژ ناموفق')
|
||||
} finally {
|
||||
renewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!selected.value) return
|
||||
Modal.confirm({
|
||||
title: 'حذف افزونه',
|
||||
content: `افزونه «${selected.value.name}» و تاریخچه شارژ آن حذف شود؟`,
|
||||
okText: 'حذف',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.delete(`/plugins/items/${selected.value!.id}`)
|
||||
message.success('حذف شد')
|
||||
selectedId.value = null
|
||||
await loadDetail()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'حذف ناموفق')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => route.params.tenantId, loadDetail)
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
|
||||
<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' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<h1 class="page-title">{{ tenant?.nameFa || 'افزونههای نسخه' }}</h1>
|
||||
<p class="page-sub">
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} · {{ tenant?.domain }}
|
||||
</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="showCreate = true">
|
||||
<PlusOutlined /> افزونه جدید
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="layout">
|
||||
<aside class="plugin-list">
|
||||
<div class="list-head">افزونهها ({{ plugins.length }})</div>
|
||||
<button
|
||||
v-for="p in plugins"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="plugin-card"
|
||||
:class="[toneClass(p.summary?.expiryLevel), { active: p.id === selectedId, inactive: !p.isActive }]"
|
||||
@click="selectPlugin(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>
|
||||
</aside>
|
||||
|
||||
<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">
|
||||
<div class="section-head">
|
||||
<h3>مشخصات افزونه</h3>
|
||||
<a-button 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>
|
||||
</a-form>
|
||||
</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="قیمت دوره (ریال)">
|
||||
<a-input-number
|
||||
v-model:value="form.priceRials"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
:min="0"
|
||||
:step="100000"
|
||||
/>
|
||||
</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 class="sticky-actions settings-save">
|
||||
<a-button type="primary" size="large" block :loading="saving" @click="savePlugin">
|
||||
ذخیره تنظیمات
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<section 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="مبلغ (ریال)">
|
||||
<a-input-number
|
||||
v-model:value="renewForm.priceRials"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
:min="0"
|
||||
:step="100000"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<div v-else class="detail empty-detail">
|
||||
افزونهای انتخاب نشده است.
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-modal
|
||||
v-model:open="showCreate"
|
||||
title="افزودن افزونه"
|
||||
ok-text="ثبت"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="creating"
|
||||
@ok="createPlugin"
|
||||
>
|
||||
<a-form layout="vertical" style="margin-top: 12px">
|
||||
<a-form-item label="نام افزونه" required>
|
||||
<a-input v-model:value="createForm.name" size="large" placeholder="نام افزونه" />
|
||||
</a-form-item>
|
||||
<a-form-item label="مشخصات">
|
||||
<a-textarea v-model:value="createForm.specs" :rows="2" />
|
||||
</a-form-item>
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="تاریخ شارژ">
|
||||
<JalaliDatePicker v-model="createForm.chargedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="مدت">
|
||||
<a-select
|
||||
v-model:value="createForm.planMonths"
|
||||
size="large"
|
||||
:options="planSelectOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="قیمت دوره (ریال)">
|
||||
<a-input-number
|
||||
v-model:value="createForm.priceRials"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
:min="0"
|
||||
:step="100000"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
font-weight: 800;
|
||||
margin-bottom: 4px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
appearance: none;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
color: #17324d;
|
||||
}
|
||||
|
||||
.plugin-card.active {
|
||||
border-color: #3b6fd4;
|
||||
}
|
||||
|
||||
.plugin-card.inactive {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.plugin-card.healthy { background: linear-gradient(135deg, #dff8ea, #c3efd7); }
|
||||
.plugin-card.warning { background: linear-gradient(135deg, #fff4c7, #ffe39a); }
|
||||
.plugin-card.critical { background: linear-gradient(135deg, #ffd8d8, #ffb9b9); }
|
||||
.plugin-card.unknown { background: linear-gradient(135deg, #eef2f7, #dde5ef); }
|
||||
|
||||
.empty-hint,
|
||||
.empty-detail,
|
||||
.hint-box {
|
||||
color: #94a3b8;
|
||||
padding: 14px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-banner.healthy { background: #dff8ea; color: #0f7a45; }
|
||||
.status-banner.warning { background: #fff4c7; color: #9a6b00; }
|
||||
.status-banner.critical { background: #ffd8d8; color: #b42318; }
|
||||
.status-banner.unknown { background: #eef2f7; color: #64748b; }
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 0 0 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.settings-save {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.renew-section {
|
||||
margin-top: 28px;
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.inline-hint {
|
||||
margin-right: 10px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.charge-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.charge-item {
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.charge-meta {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -152,11 +152,15 @@ onMounted(load)
|
||||
مدیریت
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="product === 'khanegi'"
|
||||
type="default"
|
||||
block
|
||||
style="flex: 1"
|
||||
@click="router.push({ name: 'version-ui', params: { id: item.id } })"
|
||||
@click="
|
||||
router.push({
|
||||
name: product === 'hamkar' ? 'version-ui-hamkar' : 'version-ui',
|
||||
params: { id: item.id },
|
||||
})
|
||||
"
|
||||
>
|
||||
ظاهر اپ
|
||||
</a-button>
|
||||
|
||||
+207
-13
@@ -1,8 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, CloudServerOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
|
||||
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
type TenantRow = {
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
slug: string
|
||||
nameFa: string
|
||||
domain: string
|
||||
server: {
|
||||
isDedicated: boolean
|
||||
expiryLevel: ExpiryLevel
|
||||
expiryLabel: string
|
||||
serverName?: string | null
|
||||
planLabel?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const loadingList = ref(false)
|
||||
const tenants = ref<TenantRow[]>([])
|
||||
|
||||
const groupedTenants = computed(() => ({
|
||||
hamkar: tenants.value.filter((t) => t.product === 'hamkar'),
|
||||
khanegi: tenants.value.filter((t) => t.product === 'khanegi'),
|
||||
}))
|
||||
|
||||
function toneClass(level?: ExpiryLevel) {
|
||||
return level || 'unknown'
|
||||
}
|
||||
|
||||
function openTenant(id: string) {
|
||||
router.push({ name: 'server-tenant', params: { tenantId: id } })
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
loadingList.value = true
|
||||
try {
|
||||
const { data } = await api.get('/servers/tenants')
|
||||
tenants.value = data
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در دریافت سرورها')
|
||||
} finally {
|
||||
loadingList.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTenants)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -11,24 +60,169 @@ const router = useRouter()
|
||||
<a-button type="text" @click="router.push({ name: 'home' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div>
|
||||
<div style="flex: 1">
|
||||
<h1 class="page-title">پنل سرور</h1>
|
||||
<p class="page-sub">شارژ و تمدید سرورهای نسخههای فروشی</p>
|
||||
<p class="page-sub">وضعیت اختصاصی یا مدیریتشده، شارژ و هشدار نزدیک پایان اعتبار</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-hero server">
|
||||
<div style="display: flex; align-items: center; gap: 12px; font-size: 28px">
|
||||
<CloudServerOutlined />
|
||||
<strong style="font-size: 1.2rem">بهزودی</strong>
|
||||
</div>
|
||||
<p style="margin: 12px 0 0; opacity: 0.92; line-height: 1.8">
|
||||
این بخش برای پیگیری شارژ، تمدید و وضعیت سرورها طراحی میشود — مشابه ساختار پنل پیامکی.
|
||||
</p>
|
||||
<div class="legend">
|
||||
<span class="chip dedicated">اختصاصی</span>
|
||||
<span class="chip healthy">اعتبار کافی — سبز</span>
|
||||
<span class="chip warning">نزدیک هشدار — زرد</span>
|
||||
<span class="chip critical">۳ روز یا کمتر / منقضی — قرمز</span>
|
||||
<span class="chip unknown">بدون تاریخ</span>
|
||||
</div>
|
||||
|
||||
<div class="hint-box">
|
||||
اسکلت صفحه آماده است. جزئیات业务流程 و اتصال به سرویسها را در مرحله بعد اضافه میکنیم.
|
||||
</div>
|
||||
<section class="sms-selector">
|
||||
<div class="sms-side-head"><CloudServerOutlined /> نسخهها</div>
|
||||
<a-spin :spinning="loadingList">
|
||||
<div class="tenant-groups">
|
||||
<section class="tenant-group">
|
||||
<div class="tenant-group-head">
|
||||
<h3>همکار</h3>
|
||||
<span>{{ groupedTenants.hamkar.length }} نسخه</span>
|
||||
</div>
|
||||
<div class="tenant-grid">
|
||||
<button
|
||||
v-for="t in groupedTenants.hamkar"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="tenant-card"
|
||||
:class="toneClass(t.server?.expiryLevel)"
|
||||
@click="openTenant(t.id)"
|
||||
>
|
||||
<strong>{{ t.nameFa }}</strong>
|
||||
<span>{{ t.server?.expiryLabel }}</span>
|
||||
<small v-if="t.server?.serverName">{{ t.server.serverName }}</small>
|
||||
<small v-else>{{ t.domain }}</small>
|
||||
</button>
|
||||
<div v-if="!groupedTenants.hamkar.length" class="empty-hint">نسخهای نیست</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tenant-group">
|
||||
<div class="tenant-group-head">
|
||||
<h3>خانگی</h3>
|
||||
<span>{{ groupedTenants.khanegi.length }} نسخه</span>
|
||||
</div>
|
||||
<div class="tenant-grid">
|
||||
<button
|
||||
v-for="t in groupedTenants.khanegi"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="tenant-card"
|
||||
:class="toneClass(t.server?.expiryLevel)"
|
||||
@click="openTenant(t.id)"
|
||||
>
|
||||
<strong>{{ t.nameFa }}</strong>
|
||||
<span>{{ t.server?.expiryLabel }}</span>
|
||||
<small v-if="t.server?.serverName">{{ t.server.serverName }}</small>
|
||||
<small v-else>{{ t.domain }}</small>
|
||||
</button>
|
||||
<div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخهای نیست</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</a-spin>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chip.dedicated { background: #e8eef8; color: #3b5bdb; }
|
||||
.chip.healthy { background: #dff8ea; color: #0f7a45; }
|
||||
.chip.warning { background: #fff4c7; color: #9a6b00; }
|
||||
.chip.critical { background: #ffd8d8; color: #b42318; }
|
||||
.chip.unknown { background: #eef2f7; color: #64748b; }
|
||||
|
||||
.sms-selector {
|
||||
border-radius: 22px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8eef5;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sms-side-head {
|
||||
font-weight: 800;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tenant-groups {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.tenant-group-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tenant-group-head h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tenant-group-head span {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tenant-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tenant-card {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: #17324d;
|
||||
}
|
||||
|
||||
.tenant-card strong { font-size: 0.95rem; }
|
||||
.tenant-card span,
|
||||
.tenant-card small {
|
||||
opacity: 0.92;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.tenant-card.dedicated { background: linear-gradient(135deg, #e8eef8, #d6e0f5); }
|
||||
.tenant-card.healthy { background: linear-gradient(135deg, #dff8ea, #c3efd7); }
|
||||
.tenant-card.warning { background: linear-gradient(135deg, #fff4c7, #ffe39a); }
|
||||
.tenant-card.critical { background: linear-gradient(135deg, #ffd8d8, #ffb9b9); }
|
||||
.tenant-card.unknown { background: linear-gradient(135deg, #eef2f7, #dde5ef); }
|
||||
|
||||
.empty-hint {
|
||||
grid-column: 1 / -1;
|
||||
color: #94a3b8;
|
||||
padding: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
|
||||
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
|
||||
type Summary = {
|
||||
isDedicated: boolean
|
||||
expiryLevel: ExpiryLevel
|
||||
expiryLabel: string
|
||||
daysLeft: number | null
|
||||
planLabel: string | null
|
||||
expiresAt: string | null
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const renewing = ref(false)
|
||||
const tenant = ref<{
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
slug: string
|
||||
nameFa: string
|
||||
domain: string
|
||||
} | null>(null)
|
||||
const summary = ref<Summary | null>(null)
|
||||
const charges = ref<any[]>([])
|
||||
const planOptions = ref<{ months: number; label: string }[]>([])
|
||||
|
||||
const form = reactive({
|
||||
isDedicated: false,
|
||||
serverName: '',
|
||||
specs: '',
|
||||
priceRials: 0 as number,
|
||||
chargedAt: null as string | null,
|
||||
planMonths: 1 as number,
|
||||
description: '',
|
||||
alertDaysBefore: 3,
|
||||
})
|
||||
|
||||
const renewForm = reactive({
|
||||
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
|
||||
planMonths: 1,
|
||||
priceRials: 0 as number,
|
||||
note: '',
|
||||
})
|
||||
|
||||
const tenantId = computed(() => String(route.params.tenantId || ''))
|
||||
|
||||
const planSelectOptions = computed(() =>
|
||||
(planOptions.value.length
|
||||
? planOptions.value
|
||||
: [
|
||||
{ months: 1, label: 'یکماهه' },
|
||||
{ months: 3, label: 'سهماهه' },
|
||||
{ months: 6, label: 'ششماهه' },
|
||||
{ months: 12, label: 'یکساله' },
|
||||
]
|
||||
).map((p) => ({ value: p.months, label: p.label })),
|
||||
)
|
||||
|
||||
function toneClass(level?: ExpiryLevel) {
|
||||
return level || 'unknown'
|
||||
}
|
||||
|
||||
function formatMoney(value: number | null | undefined) {
|
||||
if (value == null) return '—'
|
||||
return `${new Intl.NumberFormat('fa-IR').format(value)} ریال`
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!tenantId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/servers/tenants/${tenantId.value}`)
|
||||
tenant.value = data.tenant
|
||||
summary.value = data.summary
|
||||
charges.value = data.charges || []
|
||||
planOptions.value = data.planOptions || []
|
||||
Object.assign(form, {
|
||||
isDedicated: Boolean(data.account.isDedicated),
|
||||
serverName: data.account.serverName || '',
|
||||
specs: data.account.specs || '',
|
||||
priceRials: data.account.priceRials ?? 0,
|
||||
chargedAt: data.account.chargedAt || null,
|
||||
planMonths: data.account.planMonths || 1,
|
||||
description: data.account.description || '',
|
||||
alertDaysBefore: data.account.alertDaysBefore ?? 3,
|
||||
})
|
||||
renewForm.planMonths = data.account.planMonths || 1
|
||||
renewForm.priceRials = data.account.priceRials ?? 0
|
||||
renewForm.chargedAt = iranDateInputToIso(todayIranDateInput())
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در دریافت سرور')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAccount() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.patch(`/servers/tenants/${tenantId.value}`, {
|
||||
isDedicated: form.isDedicated,
|
||||
serverName: form.serverName || null,
|
||||
specs: form.specs || null,
|
||||
priceRials: Number(form.priceRials) || 0,
|
||||
chargedAt: form.isDedicated ? undefined : form.chargedAt,
|
||||
planMonths: form.isDedicated ? undefined : Number(form.planMonths),
|
||||
description: form.description || null,
|
||||
alertDaysBefore: Number(form.alertDaysBefore) || 3,
|
||||
})
|
||||
message.success('ذخیره شد')
|
||||
await loadDetail()
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function renewServer() {
|
||||
if (form.isDedicated) {
|
||||
message.warning('برای شارژ مدیریتشده، ابتدا سوئیچ اختصاصی را خاموش کنید')
|
||||
return
|
||||
}
|
||||
if (!renewForm.chargedAt) {
|
||||
message.warning('تاریخ شارژ لازم است')
|
||||
return
|
||||
}
|
||||
renewing.value = true
|
||||
try {
|
||||
await api.post(`/servers/tenants/${tenantId.value}/renew`, {
|
||||
chargedAt: renewForm.chargedAt,
|
||||
planMonths: Number(renewForm.planMonths),
|
||||
priceRials: Number(renewForm.priceRials),
|
||||
note: renewForm.note || undefined,
|
||||
})
|
||||
message.success('سرور شارژ/تمدید شد')
|
||||
renewForm.note = ''
|
||||
await loadDetail()
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'شارژ ناموفق')
|
||||
} finally {
|
||||
renewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.params.tenantId, loadDetail)
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
|
||||
<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' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<h1 class="page-title">{{ tenant?.nameFa || 'سرور نسخه' }}</h1>
|
||||
<p class="page-sub">
|
||||
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} · {{ tenant?.domain }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="summary" class="status-banner" :class="toneClass(summary.expiryLevel)">
|
||||
<strong>{{ summary.expiryLabel }}</strong>
|
||||
<span v-if="summary.expiresAt && !summary.isDedicated">
|
||||
انقضا: {{ formatIranDate(summary.expiresAt) }}
|
||||
<template v-if="summary.planLabel"> · {{ summary.planLabel }}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>نوع سرور</h3>
|
||||
<a-form-item label="سرور اختصاصی (مدیریت و شارژ با مشتری)">
|
||||
<a-switch v-model:checked="form.isDedicated" />
|
||||
</a-form-item>
|
||||
<p class="field-hint">
|
||||
اگر روشن باشد فقط ثبت میشود که سرور اختصاصی است و هشدار/شارژ از طرف ما لازم نیست.
|
||||
اگر خاموش باشد، مشخصات و دوره اعتبار را اینجا مدیریت کنید.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>مشخصات</h3>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام سرور">
|
||||
<a-input v-model:value="form.serverName" size="large" placeholder="مثلاً Hetzner CX31 / ایرانسرور" />
|
||||
</a-form-item>
|
||||
<a-form-item label="مشخصات">
|
||||
<a-textarea
|
||||
v-model:value="form.specs"
|
||||
:rows="3"
|
||||
placeholder="CPU / RAM / دیسک / IP / دیتاسنتر…"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="توضیحات">
|
||||
<a-textarea v-model:value="form.description" :rows="3" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<section v-if="!form.isDedicated" 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="قیمت دوره (ریال)">
|
||||
<a-input-number
|
||||
v-model:value="form.priceRials"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
:min="0"
|
||||
:step="100000"
|
||||
/>
|
||||
</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 class="sticky-actions settings-save">
|
||||
<a-button type="primary" size="large" block :loading="saving" @click="saveAccount">
|
||||
ذخیره تنظیمات
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<section v-if="!form.isDedicated" 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="مبلغ (ریال)">
|
||||
<a-input-number
|
||||
v-model:value="renewForm.priceRials"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
:min="0"
|
||||
:step="100000"
|
||||
/>
|
||||
</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="renewServer">
|
||||
ثبت شارژ / تمدید
|
||||
</a-button>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<section v-if="!form.isDedicated" class="form-section">
|
||||
<h3>تاریخچه شارژ</h3>
|
||||
<div v-if="charges.length" class="charge-list">
|
||||
<article v-for="item in 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>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-banner {
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-banner.dedicated { background: #e8eef8; color: #3b5bdb; }
|
||||
.status-banner.healthy { background: #dff8ea; color: #0f7a45; }
|
||||
.status-banner.warning { background: #fff4c7; color: #9a6b00; }
|
||||
.status-banner.critical { background: #ffd8d8; color: #b42318; }
|
||||
.status-banner.unknown { background: #eef2f7; color: #64748b; }
|
||||
|
||||
.field-hint {
|
||||
margin: 0 0 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.settings-save {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.renew-section {
|
||||
margin-top: 28px;
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.inline-hint {
|
||||
margin-right: 10px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.charge-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.charge-item {
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.charge-meta {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
+807
-230
File diff suppressed because it is too large
Load Diff
+104
-31
@@ -2,13 +2,14 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined } from '@ant-design/icons-vue'
|
||||
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const uploading = ref(false)
|
||||
|
||||
const isEdit = computed(() => Boolean(route.params.id))
|
||||
|
||||
@@ -19,9 +20,7 @@ const form = reactive({
|
||||
nameEn: '',
|
||||
domain: '',
|
||||
avatarUrl: '',
|
||||
primaryColor: '#1d4ed8',
|
||||
secondaryColor: '#0f172a',
|
||||
backgroundColor: '#ffffff',
|
||||
appVersion: '1',
|
||||
nodeServer: '',
|
||||
nodeWs: '',
|
||||
appUrl: '',
|
||||
@@ -44,9 +43,6 @@ async function load() {
|
||||
if (!isEdit.value) {
|
||||
const q = String(route.query.product || '')
|
||||
form.product = q === 'khanegi' ? 'khanegi' : 'hamkar'
|
||||
if (form.product === 'khanegi') {
|
||||
form.primaryColor = '#b45309'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,9 +56,7 @@ async function load() {
|
||||
nameEn: data.nameEn || '',
|
||||
domain: data.domain,
|
||||
avatarUrl: data.avatarUrl || '',
|
||||
primaryColor: data.primaryColor || '#1d4ed8',
|
||||
secondaryColor: data.secondaryColor || '#0f172a',
|
||||
backgroundColor: data.backgroundColor || '#ffffff',
|
||||
appVersion: data.appVersion || '1',
|
||||
nodeServer: data.nodeServer,
|
||||
nodeWs: data.nodeWs || '',
|
||||
appUrl: data.appUrl || '',
|
||||
@@ -78,6 +72,35 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLogo(options: {
|
||||
file: string | Blob | File
|
||||
onSuccess?: (body?: unknown) => void
|
||||
onError?: (e: Error) => void
|
||||
}) {
|
||||
const file = options.file
|
||||
if (!(file instanceof File) && !(file instanceof Blob)) return
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
const { data } = await api.post<{ url: string }>('/tenants/upload-avatar', body)
|
||||
form.avatarUrl = data.url
|
||||
options.onSuccess?.(data)
|
||||
message.success('لوگو آپلود شد')
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
options.onError?.(e instanceof Error ? e : new Error(String(msg || 'upload failed')))
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'آپلود ناموفق')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogo() {
|
||||
form.avatarUrl = ''
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
@@ -85,7 +108,6 @@ async function save() {
|
||||
if (!payload.remoteApiToken) {
|
||||
delete (payload as any).remoteApiToken
|
||||
}
|
||||
// node_ws فقط برای همکار حیاتی است؛ برای خانگی اختیاری (SSE)
|
||||
if (isEdit.value) {
|
||||
await api.patch(`/tenants/${route.params.id}`, payload)
|
||||
message.success('بهروزرسانی شد')
|
||||
@@ -150,27 +172,34 @@ onMounted(load)
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>برند و ظاهر</h3>
|
||||
<a-form-item label="لوگو (URL)" name="avatarUrl">
|
||||
<a-input v-model:value="form.avatarUrl" size="large" dir="ltr" placeholder="https://..." />
|
||||
<h3>لوگو</h3>
|
||||
<p class="field-hint">
|
||||
از طریق /get_user بهعنوان <code dir="ltr">avatar</code> به نسخهها فرستاده میشود.
|
||||
<template v-if="form.product === 'khanegi'"> رنگ و تم از صفحه «ظاهر اپ» تنظیم میشود.</template>
|
||||
</p>
|
||||
<a-form-item name="avatarUrl">
|
||||
<div class="logo-upload-row">
|
||||
<a-upload
|
||||
name="file"
|
||||
list-type="picture-card"
|
||||
:show-upload-list="false"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml"
|
||||
:custom-request="uploadLogo"
|
||||
:disabled="uploading"
|
||||
>
|
||||
<div v-if="form.avatarUrl" class="logo-preview">
|
||||
<img :src="form.avatarUrl" alt="لوگو" />
|
||||
</div>
|
||||
<div v-else class="logo-placeholder">
|
||||
<PlusOutlined />
|
||||
<div>{{ uploading ? 'در حال آپلود…' : 'آپلود لوگو' }}</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<a-button v-if="form.avatarUrl" danger @click="clearLogo">
|
||||
<DeleteOutlined /> حذف لوگو
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="اصلی">
|
||||
<a-input v-model:value="form.primaryColor" type="color" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="ثانویه">
|
||||
<a-input v-model:value="form.secondaryColor" type="color" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="پسزمینه">
|
||||
<a-input v-model:value="form.backgroundColor" type="color" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
@@ -200,6 +229,17 @@ onMounted(load)
|
||||
<a-form-item label="فعال در رجیستری">
|
||||
<a-switch v-model:checked="form.isActive" />
|
||||
</a-form-item>
|
||||
<a-form-item label="ورژن اپ" name="appVersion">
|
||||
<a-input
|
||||
v-model:value="form.appVersion"
|
||||
size="large"
|
||||
dir="ltr"
|
||||
placeholder="مثلاً 2.0.1"
|
||||
/>
|
||||
<template #extra>
|
||||
از طریق <code dir="ltr">/get_version</code> به اپ فرستاده میشود. با تغییرش، کلاینت کش کانفیگ را رفرش و صفحه را reload میکند.
|
||||
</template>
|
||||
</a-form-item>
|
||||
<a-form-item label="یادداشت داخلی">
|
||||
<a-textarea v-model:value="form.notes" :rows="3" />
|
||||
</a-form-item>
|
||||
@@ -215,3 +255,36 @@ onMounted(load)
|
||||
<a-spin />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.field-hint {
|
||||
margin: 0 0 12px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.logo-upload-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logo-preview,
|
||||
.logo-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.logo-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
|
||||
+282
-83
@@ -1,11 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
import { createDefaultKhanegiUiConfig } from '@/config/defaultKhanegiUiConfig'
|
||||
import { colorFieldLabels, colorPalettes } from '@/theme/tokens'
|
||||
import {
|
||||
applyKhanegiPreset,
|
||||
khanegiThemePresets,
|
||||
markCustomAndSyncSchemes,
|
||||
resolvePresetId,
|
||||
syncActiveColorsFromMode,
|
||||
type KhanegiPresetId,
|
||||
} from '@/config/khanegiThemePresets'
|
||||
import { colorFieldLabels } from '@/theme/tokens'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -16,28 +24,63 @@ const product = ref<'hamkar' | 'khanegi'>('khanegi')
|
||||
const activeTab = ref('theme')
|
||||
const advancedJson = ref('')
|
||||
const jsonError = ref('')
|
||||
const selectedPreset = ref<KhanegiPresetId>('navy-gold')
|
||||
const previewMode = ref<'dark' | 'light'>('dark')
|
||||
|
||||
const config = reactive(createDefaultKhanegiUiConfig())
|
||||
|
||||
const isCustom = computed(() => selectedPreset.value === 'custom')
|
||||
|
||||
const activePreviewColors = computed(() => {
|
||||
const schemes = config.app.theme.schemes
|
||||
if (schemes?.[previewMode.value]) return schemes[previewMode.value]
|
||||
return config.app.theme.colors
|
||||
})
|
||||
|
||||
const colorKeys = computed(() => Object.keys(config.app.theme.colors))
|
||||
|
||||
const previewStyle = computed(() => ({
|
||||
background: config.app.theme.colors.pageBg,
|
||||
color: config.app.theme.colors.textPrimary,
|
||||
background: activePreviewColors.value.pageBg,
|
||||
color: activePreviewColors.value.textPrimary,
|
||||
borderRadius: `${config.app.theme.borderRadius.lg}px`,
|
||||
}))
|
||||
|
||||
const accentStyle = computed(() => ({
|
||||
background: config.app.theme.colors.secondary,
|
||||
color: config.app.theme.colors.onAccent,
|
||||
background: activePreviewColors.value.secondary,
|
||||
color: activePreviewColors.value.onAccent,
|
||||
borderRadius: `${config.app.theme.borderRadius.md}px`,
|
||||
}))
|
||||
|
||||
function applyPalette(id: string) {
|
||||
const palette = colorPalettes.find((p) => p.id === id)
|
||||
if (!palette) return
|
||||
Object.assign(config.app.theme.colors, palette.colors)
|
||||
message.success(`پالت «${palette.name}» اعمال شد`)
|
||||
function selectPreset(id: KhanegiPresetId) {
|
||||
selectedPreset.value = id
|
||||
if (id === 'custom') {
|
||||
config.app.theme.presetId = 'custom'
|
||||
if (!config.app.theme.schemes) {
|
||||
config.app.theme.schemes = {
|
||||
dark: { ...config.app.theme.colors },
|
||||
light: { ...config.app.theme.colors },
|
||||
}
|
||||
}
|
||||
message.info('حالت سفارشی — میتوانید جزئیات را ویرایش کنید')
|
||||
return
|
||||
}
|
||||
applyKhanegiPreset(config, id)
|
||||
previewMode.value = config.app.theme.mode === 'light' ? 'light' : 'dark'
|
||||
syncAdvancedJson()
|
||||
message.success(`تم «${khanegiThemePresets.find((p) => p.id === id)?.nameFa}» اعمال شد`)
|
||||
}
|
||||
|
||||
function onModeChange() {
|
||||
syncActiveColorsFromMode(config)
|
||||
previewMode.value = config.app.theme.mode === 'light' ? 'light' : 'dark'
|
||||
if (isCustom.value) markCustomAndSyncSchemes(config)
|
||||
}
|
||||
|
||||
function onColorEdit() {
|
||||
if (!isCustom.value) {
|
||||
selectedPreset.value = 'custom'
|
||||
}
|
||||
markCustomAndSyncSchemes(config)
|
||||
}
|
||||
|
||||
function syncAdvancedJson() {
|
||||
@@ -48,7 +91,9 @@ function syncAdvancedJson() {
|
||||
function applyAdvancedJson() {
|
||||
try {
|
||||
const parsed = JSON.parse(advancedJson.value)
|
||||
Object.assign(config, parsed)
|
||||
Object.assign(config, createDefaultKhanegiUiConfig(), parsed)
|
||||
ensureSchemes()
|
||||
selectedPreset.value = resolvePresetId(config)
|
||||
jsonError.value = ''
|
||||
message.success('JSON اعمال شد')
|
||||
} catch (e: any) {
|
||||
@@ -56,6 +101,18 @@ function applyAdvancedJson() {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSchemes() {
|
||||
if (!config.app.theme.schemes?.dark || !config.app.theme.schemes?.light) {
|
||||
config.app.theme.schemes = {
|
||||
dark: { ...config.app.theme.colors },
|
||||
light: { ...config.app.theme.colors },
|
||||
}
|
||||
}
|
||||
if (!config.app.theme.presetId) {
|
||||
config.app.theme.presetId = 'custom'
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -63,7 +120,11 @@ async function load() {
|
||||
tenantName.value = data.nameFa
|
||||
product.value = data.product
|
||||
if (data.product !== 'khanegi') {
|
||||
message.warning('ظاهر اپ فعلاً فقط برای خانگی فعال است')
|
||||
if (data.product === 'hamkar') {
|
||||
router.replace({ name: 'version-ui-hamkar', params: { id: String(route.params.id) } })
|
||||
return
|
||||
}
|
||||
message.warning('ظاهر اپ برای این محصول فعال نیست')
|
||||
router.replace({ name: 'version-edit', params: { id: String(route.params.id) } })
|
||||
return
|
||||
}
|
||||
@@ -73,6 +134,9 @@ async function load() {
|
||||
Object.assign(config, createDefaultKhanegiUiConfig())
|
||||
}
|
||||
}
|
||||
ensureSchemes()
|
||||
selectedPreset.value = resolvePresetId(config)
|
||||
previewMode.value = config.app.theme.mode === 'light' ? 'light' : 'dark'
|
||||
syncAdvancedJson()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در بارگذاری')
|
||||
@@ -84,6 +148,12 @@ async function load() {
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
ensureSchemes()
|
||||
config.app.theme.presetId = selectedPreset.value
|
||||
syncActiveColorsFromMode(config)
|
||||
if (selectedPreset.value === 'custom') {
|
||||
markCustomAndSyncSchemes(config)
|
||||
}
|
||||
const colors = config.app.theme.colors
|
||||
await api.patch(`/tenants/${route.params.id}`, {
|
||||
appUiConfig: JSON.parse(JSON.stringify(config)),
|
||||
@@ -101,6 +171,13 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => config.app.theme.mode,
|
||||
() => {
|
||||
if (!loading.value) onModeChange()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -112,20 +189,66 @@ onMounted(load)
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<h1 class="page-title">ظاهر اپ خانگی</h1>
|
||||
<p class="page-sub">{{ tenantName }} · app_ui_config</p>
|
||||
<p class="page-sub">{{ tenantName }} · ۳ تم آماده + سفارشی</p>
|
||||
</div>
|
||||
<a-button type="primary" size="large" :loading="saving" @click="save">ذخیره ظاهر</a-button>
|
||||
</div>
|
||||
|
||||
<section class="form-section preset-section">
|
||||
<h3>انتخاب تم</h3>
|
||||
<div class="preset-grid">
|
||||
<button
|
||||
v-for="p in khanegiThemePresets"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="preset-card"
|
||||
:class="{ active: selectedPreset === p.id }"
|
||||
@click="selectPreset(p.id)"
|
||||
>
|
||||
<div class="preset-swatches">
|
||||
<span :style="{ background: p.preview.primary }" />
|
||||
<span :style="{ background: p.preview.secondary }" />
|
||||
<span :style="{ background: p.preview.pageBg }" />
|
||||
<span :style="{ background: p.preview.surface }" />
|
||||
</div>
|
||||
<div class="preset-name">{{ p.nameFa }}</div>
|
||||
<div class="preset-desc">{{ p.description }}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="preset-card custom-card"
|
||||
:class="{ active: selectedPreset === 'custom' }"
|
||||
@click="selectPreset('custom')"
|
||||
>
|
||||
<div class="preset-swatches custom-swatches">
|
||||
<span /><span /><span /><span />
|
||||
</div>
|
||||
<div class="preset-name">سفارشی</div>
|
||||
<div class="preset-desc">شخصیسازی رنگ و جزئیات</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="preview-phone" :style="previewStyle">
|
||||
<div class="preview-bar">
|
||||
<span>{{ tenantName || 'پیشنمایش' }}</span>
|
||||
<div class="preview-modes">
|
||||
<button type="button" :class="{ on: previewMode === 'dark' }" @click="previewMode = 'dark'">تیره</button>
|
||||
<button type="button" :class="{ on: previewMode === 'light' }" @click="previewMode = 'light'">روشن</button>
|
||||
</div>
|
||||
<span class="preview-chip" :style="accentStyle">اکسنت</span>
|
||||
</div>
|
||||
<div class="preview-card" :style="{ background: config.app.theme.colors.surface, borderRadius: config.app.theme.borderRadius.md + 'px' }">
|
||||
<div
|
||||
class="preview-card"
|
||||
:style="{
|
||||
background: activePreviewColors.surface,
|
||||
borderRadius: config.app.theme.borderRadius.md + 'px',
|
||||
color: activePreviewColors.textPrimary,
|
||||
}"
|
||||
>
|
||||
<div style="opacity: 0.7; font-size: 12px">surface</div>
|
||||
<div style="margin-top: 8px; font-weight: 700">نمونه کارت اپ</div>
|
||||
<div style="margin-top: 6px; font-size: 12px" :style="{ color: config.app.theme.colors.textSecondary }">
|
||||
<div style="margin-top: 6px; font-size: 12px" :style="{ color: activePreviewColors.textSecondary }">
|
||||
متن فرعی با رنگ theme
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,29 +256,16 @@ onMounted(load)
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" class="ui-tabs">
|
||||
<a-tab-pane key="theme" tab="تم و رنگها">
|
||||
<section class="form-section">
|
||||
<h3>پالتهای آماده</h3>
|
||||
<div class="palette-grid">
|
||||
<button
|
||||
v-for="p in colorPalettes"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="palette-card"
|
||||
@click="applyPalette(p.id)"
|
||||
>
|
||||
<div class="palette-swatches">
|
||||
<span :style="{ background: p.colors.primary }" />
|
||||
<span :style="{ background: p.colors.secondary }" />
|
||||
<span :style="{ background: p.colors.pageBg }" />
|
||||
<span :style="{ background: p.colors.surface }" />
|
||||
</div>
|
||||
<div class="palette-name">{{ p.name }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<a-alert
|
||||
v-if="!isCustom"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
message="برای ویرایش دستی رنگها، «سفارشی» را انتخاب کنید یا یکی از فیلدها را تغییر دهید."
|
||||
/>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>حالت تم</h3>
|
||||
<h3>حالت پیشفرض اپ</h3>
|
||||
<a-radio-group v-model:value="config.app.theme.mode" button-style="solid">
|
||||
<a-radio-button value="dark">تیره</a-radio-button>
|
||||
<a-radio-button value="light">روشن</a-radio-button>
|
||||
@@ -163,7 +273,7 @@ onMounted(load)
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>رنگها</h3>
|
||||
<h3>رنگها {{ isCustom ? '(سفارشی)' : '(فقطخواندنی تا انتخاب سفارشی)' }}</h3>
|
||||
<div class="color-grid">
|
||||
<label v-for="key in colorKeys" :key="key" class="color-field">
|
||||
<span>{{ colorFieldLabels[key] || key }}</span>
|
||||
@@ -177,8 +287,15 @@ onMounted(load)
|
||||
v-model="(config.app.theme.colors as any)[key]"
|
||||
type="color"
|
||||
class="color-native"
|
||||
:disabled="!isCustom"
|
||||
@change="onColorEdit"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="(config.app.theme.colors as any)[key]"
|
||||
dir="ltr"
|
||||
:disabled="!isCustom"
|
||||
@change="onColorEdit"
|
||||
/>
|
||||
<a-input v-model:value="(config.app.theme.colors as any)[key]" dir="ltr" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -189,17 +306,38 @@ onMounted(load)
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="sm">
|
||||
<a-input-number v-model:value="config.app.theme.borderRadius.sm" :min="0" :max="40" style="width: 100%" />
|
||||
<a-input-number
|
||||
v-model:value="config.app.theme.borderRadius.sm"
|
||||
:min="0"
|
||||
:max="40"
|
||||
style="width: 100%"
|
||||
:disabled="!isCustom"
|
||||
@change="onColorEdit"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="md">
|
||||
<a-input-number v-model:value="config.app.theme.borderRadius.md" :min="0" :max="40" style="width: 100%" />
|
||||
<a-input-number
|
||||
v-model:value="config.app.theme.borderRadius.md"
|
||||
:min="0"
|
||||
:max="40"
|
||||
style="width: 100%"
|
||||
:disabled="!isCustom"
|
||||
@change="onColorEdit"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="lg">
|
||||
<a-input-number v-model:value="config.app.theme.borderRadius.lg" :min="0" :max="48" style="width: 100%" />
|
||||
<a-input-number
|
||||
v-model:value="config.app.theme.borderRadius.lg"
|
||||
:min="0"
|
||||
:max="48"
|
||||
style="width: 100%"
|
||||
:disabled="!isCustom"
|
||||
@change="onColorEdit"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -257,7 +395,7 @@ onMounted(load)
|
||||
|
||||
<a-tab-pane key="json" tab="JSON پیشرفته">
|
||||
<p class="page-sub" style="margin-bottom: 12px">
|
||||
برای pages و جزئیات کامل میتوانید JSON را مستقیم ویرایش کنید. اگر از قبل در دیتابیس مشتری pages دارید، اینجا پیست کنید.
|
||||
برای pages و جزئیات کامل میتوانید JSON را مستقیم ویرایش کنید.
|
||||
</p>
|
||||
<a-textarea v-model:value="advancedJson" :rows="18" dir="ltr" class="json-box" />
|
||||
<div v-if="jsonError" style="color: #dc2626; margin-top: 8px">{{ jsonError }}</div>
|
||||
@@ -285,6 +423,74 @@ onMounted(load)
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.preset-card {
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
font-family: inherit;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.preset-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.preset-card.active {
|
||||
border-color: var(--sa-primary, #20c997);
|
||||
box-shadow: 0 0 0 3px rgba(32, 201, 151, 0.18);
|
||||
}
|
||||
|
||||
.preset-swatches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.preset-swatches span {
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.custom-swatches span {
|
||||
background: linear-gradient(135deg, #e2e8f0, #94a3b8);
|
||||
}
|
||||
|
||||
.custom-swatches span:nth-child(2) {
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
}
|
||||
|
||||
.custom-swatches span:nth-child(3) {
|
||||
background: linear-gradient(135deg, #cbd5e1, #64748b);
|
||||
}
|
||||
|
||||
.custom-swatches span:nth-child(4) {
|
||||
background: linear-gradient(135deg, #1e293b, #0f172a);
|
||||
}
|
||||
|
||||
.preset-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.preset-desc {
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.preview-phone {
|
||||
padding: 16px;
|
||||
margin-bottom: 18px;
|
||||
@@ -296,8 +502,36 @@ onMounted(load)
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 700;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preview-modes {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.preview-modes button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preview-modes button.on {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-chip {
|
||||
@@ -310,46 +544,6 @@ onMounted(load)
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.palette-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.palette-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
font-family: inherit;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.palette-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.palette-swatches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.palette-swatches span {
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.palette-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.color-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -383,6 +577,11 @@ onMounted(load)
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-native:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
|
||||
Reference in New Issue
Block a user