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:
2026-07-30 18:19:54 +03:30
co-authored by Cursor
parent 3371015b96
commit cfee6b0546
27 changed files with 4528 additions and 408 deletions
+271
View File
@@ -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>
+3 -2
View File
@@ -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>
+469
View File
@@ -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
View File
@@ -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
View File
@@ -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="رمز عبور"
+209
View File
@@ -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>
+619
View File
@@ -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>
+6 -2
View File
@@ -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
View File
@@ -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>
+379
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+104 -31
View File
@@ -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
View File
@@ -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;