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
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed } from 'vue'
import Vue3PersianDatetimePicker from 'vue3-persian-datetime-picker'
import {
iranDateInputToIso,
isoToIranDateInput,
todayIranDateInput,
} from '@/utils/datetime'
const props = withDefaults(
defineProps<{
modelValue?: string | null
/** 'iso' = ISO string for API; 'ymd' = YYYY-MM-DD Gregorian (Iran day) */
valueType?: 'iso' | 'ymd'
placeholder?: string
disabled?: boolean
clearable?: boolean
size?: 'large' | 'middle' | 'small'
}>(),
{
modelValue: null,
valueType: 'iso',
placeholder: 'انتخاب تاریخ',
disabled: false,
clearable: true,
size: 'large',
},
)
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const pickerValue = computed({
get() {
if (!props.modelValue) return ''
if (props.valueType === 'ymd') return props.modelValue
return isoToIranDateInput(props.modelValue)
},
set(val: string | null | undefined) {
const raw = (val || '').trim()
if (!raw) {
emit('update:modelValue', null)
return
}
if (props.valueType === 'ymd') {
emit('update:modelValue', raw)
return
}
emit('update:modelValue', iranDateInputToIso(raw))
},
})
function setToday() {
const ymd = todayIranDateInput()
if (props.valueType === 'ymd') {
emit('update:modelValue', ymd)
} else {
emit('update:modelValue', iranDateInputToIso(ymd))
}
}
</script>
<template>
<div class="jalali-date-field" :class="[`size-${size}`]">
<Vue3PersianDatetimePicker
v-model="pickerValue"
simple
:disabled="disabled"
:clearable="clearable"
format="YYYY-MM-DD"
display-format="jYYYY/jMM/jDD"
:placeholder="placeholder"
input-class="jalali-date-input"
class="jalali-picker"
/>
<button
v-if="!disabled"
type="button"
class="today-btn"
@click="setToday"
>
امروز
</button>
</div>
</template>
<style scoped>
.jalali-date-field {
display: flex;
gap: 8px;
align-items: stretch;
width: 100%;
}
.jalali-date-field :deep(.vpd-input-group),
.jalali-date-field :deep(.jalali-picker) {
flex: 1;
width: 100%;
}
.jalali-date-field :deep(.jalali-date-input),
.jalali-date-field :deep(input) {
width: 100%;
height: 40px;
border: 1px solid #d9d9d9;
border-radius: 8px;
padding: 4px 11px;
font-family: inherit;
font-size: 14px;
direction: ltr;
text-align: left;
background: #fff;
}
.size-large :deep(.jalali-date-input),
.size-large :deep(input) {
height: 48px;
font-size: 16px;
border-radius: 10px;
}
.jalali-date-field :deep(input:focus) {
border-color: #20c997;
outline: none;
box-shadow: 0 0 0 2px rgba(32, 201, 151, 0.15);
}
.today-btn {
appearance: none;
border: 1px solid #d9d9d9;
background: #f8fafc;
border-radius: 10px;
padding: 0 12px;
cursor: pointer;
font-family: inherit;
font-weight: 700;
color: #475569;
white-space: nowrap;
}
.today-btn:hover {
border-color: #20c997;
color: #0f766e;
}
</style>
+139
View File
@@ -0,0 +1,139 @@
export type HamkarPricingBoxTheme = '1' | '2' | '3' | '4'
export type HamkarOrderUi = 'new' | 'old'
export type HamkarPriceFontSize = 'کوچک' | 'متوسط' | 'بزرگ' | 'بزرگ‌تر'
export type HamkarUiConfig = {
kind: 'hamkar'
/**
* When false, GET /get_user will NOT emit ui_settings —
* Laravel settings stay source of truth (prevents visual jumps).
*/
settingsManaged: boolean
/** Named CSS mode from live registry (Montpar, limangold, …) or dynamicMode */
registryMode: string
brand: {
/** Registry `color` → CSS `--bg2` (only when mode=dynamicMode) */
primaryColor: string
/** Registry `secondary_color` → CSS `--colorPrimary` (dynamicMode) */
secondaryColor: string
/** Registry `background_color` → CSS `--bg` (dynamicMode) */
backgroundColor: string
}
settings: {
pricing_box_theme: HamkarPricingBoxTheme
price_font_size: HamkarPriceFontSize
orderUI: HamkarOrderUi
}
}
export const HAMKAR_PRICING_BOX_THEME_OPTIONS: {
value: HamkarPricingBoxTheme
label: string
}[] = [
{ value: '1', label: 'تم ۱ (پیش‌فرض)' },
{ value: '2', label: 'تم ۲' },
{ value: '3', label: 'تم ۳' },
{ value: '4', label: 'تم ۴' },
]
export const HAMKAR_ORDER_UI_OPTIONS: { value: HamkarOrderUi; label: string }[] = [
{ value: 'new', label: 'جدید (کارتی)' },
{ value: 'old', label: 'قدیمی (جدولی)' },
]
export const HAMKAR_PRICE_FONT_SIZE_OPTIONS: {
value: HamkarPriceFontSize
label: string
}[] = [
{ value: 'کوچک', label: 'کوچک' },
{ value: 'متوسط', label: 'متوسط' },
{ value: 'بزرگ', label: 'بزرگ' },
{ value: 'بزرگ‌تر', label: 'بزرگ‌تر' },
]
function asColor(value: unknown): string {
if (value == null) return ''
const s = String(value).trim()
if (!s || s === 'null' || s === 'undefined') return ''
return s
}
export function createDefaultHamkarUiConfig(): HamkarUiConfig {
return {
kind: 'hamkar',
settingsManaged: false,
registryMode: 'dynamicMode',
brand: {
primaryColor: '',
secondaryColor: '',
backgroundColor: '',
},
settings: {
pricing_box_theme: '1',
price_font_size: 'بزرگ',
orderUI: 'old',
},
}
}
export function normalizeHamkarUiConfig(raw: unknown): HamkarUiConfig {
const base = createDefaultHamkarUiConfig()
if (!raw || typeof raw !== 'object') return base
const input = raw as Record<string, any>
const brand = input.brand && typeof input.brand === 'object' ? input.brand : {}
const settings =
input.settings && typeof input.settings === 'object' ? input.settings : {}
const theme = String(settings.pricing_box_theme ?? base.settings.pricing_box_theme)
const orderUI = String(settings.orderUI ?? base.settings.orderUI)
const font = String(settings.price_font_size ?? base.settings.price_font_size)
const registryMode = String(input.registryMode || base.registryMode).trim() || 'dynamicMode'
return {
kind: 'hamkar',
settingsManaged: Boolean(input.settingsManaged),
registryMode,
brand: {
// Do not invent colors — empty means null in registry/get_user
primaryColor: asColor(brand.primaryColor),
secondaryColor: asColor(brand.secondaryColor),
backgroundColor: asColor(brand.backgroundColor),
},
settings: {
pricing_box_theme: (['1', '2', '3', '4'].includes(theme)
? theme
: base.settings.pricing_box_theme) as HamkarPricingBoxTheme,
orderUI: (orderUI === 'old' || orderUI === 'new'
? orderUI
: base.settings.orderUI) as HamkarOrderUi,
price_font_size: (HAMKAR_PRICE_FONT_SIZE_OPTIONS.some((o) => o.value === font)
? font
: base.settings.price_font_size) as HamkarPriceFontSize,
},
}
}
/** Payload for API: empty color strings → null */
export function hamkarUiToApiPayload(config: HamkarUiConfig) {
const normalized = normalizeHamkarUiConfig(config)
const emptyToNull = (v: string) => (v.trim() ? v.trim() : null)
return {
...normalized,
brand: {
primaryColor: emptyToNull(normalized.brand.primaryColor),
secondaryColor: emptyToNull(normalized.brand.secondaryColor),
backgroundColor: emptyToNull(normalized.brand.backgroundColor),
},
}
}
/** Build Laravel-compatible settings overlay for Hamkar user app. */
export function hamkarUiToSettingsList(config: HamkarUiConfig) {
if (!config.settingsManaged) return []
return [
{ key: 'pricing_box_theme', value: config.settings.pricing_box_theme },
{ key: 'price_font_size', value: config.settings.price_font_size },
{ key: 'orderUI', value: config.settings.orderUI },
]
}
+67 -20
View File
@@ -1,3 +1,63 @@
export type ThemeColors = {
primary: string
secondary: string
tertiary: string
background: string
surface: string
surfaceHover: string
textPrimary: string
textSecondary: string
textSubtle: string
border: string
borderSubtle: string
pageBg: string
pageBgMobile: string
onAccent: string
success: string
warning: string
danger: string
}
const navyGoldDark: ThemeColors = {
primary: '#0A1B37',
secondary: '#e3b056',
tertiary: '#ce9d43',
background: '#ffffff',
surface: '#222228',
surfaceHover: '#2c2c34',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.45)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.16)',
borderSubtle: 'rgba(255, 255, 255, 0.12)',
pageBg: '#121216',
pageBgMobile: '#0e0e12',
onAccent: '#0a0a0a',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
}
const navyGoldLight: ThemeColors = {
primary: '#0A1B37',
secondary: '#c9973a',
tertiary: '#b98633',
background: '#faf8f5',
surface: '#ffffff',
surfaceHover: '#fffefb',
textPrimary: '#1c1916',
textSecondary: '#5c564d',
textSubtle: '#8a8278',
border: 'rgba(184, 153, 88, 0.22)',
borderSubtle: 'rgba(0, 0, 0, 0.08)',
pageBg: '#f5f2ed',
pageBgMobile: '#faf8f5',
onAccent: '#ffffff',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
}
/** Default Khanegi app.theme + navigation shell for new UI configs */
export function createDefaultKhanegiUiConfig() {
return {
@@ -10,25 +70,12 @@ export function createDefaultKhanegiUiConfig() {
locale: 'fa-IR',
startRoute: 'home',
theme: {
mode: 'dark',
colors: {
primary: '#0A1B37',
secondary: '#e3b056',
tertiary: '#ce9d43',
background: '#ffffff',
surface: '#222228',
surfaceHover: '#2c2c34',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.45)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.16)',
borderSubtle: 'rgba(255, 255, 255, 0.12)',
pageBg: '#121216',
pageBgMobile: '#0e0e12',
onAccent: '#0a0a0a',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
presetId: 'navy-gold' as string,
mode: 'dark' as 'dark' | 'light' | 'system',
colors: { ...navyGoldDark },
schemes: {
dark: { ...navyGoldDark },
light: { ...navyGoldLight },
},
borderRadius: { sm: 8, md: 12, lg: 16 },
},
@@ -65,7 +112,7 @@ export function createDefaultKhanegiUiConfig() {
brandSubtitle: 'پنل کاربری',
},
},
pages: {},
pages: {} as Record<string, unknown>,
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Mirrors Hamkar user `apply-theme.ts` + default CSS in `main.less` / `maindynamic.less`.
* Named modes (Montpar, limangold, …) do NOT paint registry color fields onto the chrome.
*/
export type HamkarEffectiveTheme = {
mode: string
isDynamic: boolean
/** What paints the top header / mobile menu bar */
headerBg: string
/** Selected menu / text_Secondary / icon accent the user actually sees */
accent: string
/** Ant Design colorPrimary token (buttons) */
antPrimary: string
/** Page / body background */
pageBg: string
/** CSS vars apply-theme writes on :root */
cssVars: {
bg: string
bg2: string
colorPrimary: string
}
note: string
}
const NAMED_DEFAULT = {
headerBg: '#ffffff',
accent: '#2563EB',
antPrimary: '#1d4ed8',
pageBg: '#fafafa',
cssVars: {
bg: '',
bg2: '#ffffff',
colorPrimary: '#1d4ed8',
},
} as const
function hexOr(value: string | null | undefined, fallback: string) {
const s = (value || '').trim()
if (!s || s === 'null' || s === 'undefined') return fallback
return s
}
export function resolveHamkarEffectiveTheme(input: {
registryMode?: string | null
primaryColor?: string | null
secondaryColor?: string | null
backgroundColor?: string | null
}): HamkarEffectiveTheme {
const mode = String(input.registryMode || 'dynamicMode').trim() || 'dynamicMode'
const isDynamic = mode === 'dynamicMode'
if (!isDynamic) {
return {
mode,
isDynamic: false,
...NAMED_DEFAULT,
note: `حالت «${mode}»: هدر و منو از CSS پیش‌فرض همکار می‌آیند؛ فیلدهای color / secondary / bg رجیستری روی ظاهر اعمال نمی‌شوند.`,
}
}
const panel = hexOr(input.primaryColor, '#ffffff')
const accent = hexOr(input.secondaryColor, '#1d4ed8')
const pageBg = hexOr(input.backgroundColor, '#ffffff')
return {
mode,
isDynamic: true,
headerBg: panel,
accent,
antPrimary: accent,
pageBg,
cssVars: {
bg: pageBg,
bg2: panel,
colorPrimary: accent,
},
note: 'حالت dynamicMode: color → هدر (--bg2)، secondary_color → اکسنت (--colorPrimary)، background_color → پس‌زمینه (--bg).',
}
}
+222
View File
@@ -0,0 +1,222 @@
import { createDefaultKhanegiUiConfig, type ThemeColors, type UiConfig } from './defaultKhanegiUiConfig'
export type KhanegiPresetId = 'navy-gold' | 'emerald' | 'warm-bronze' | 'custom'
export type ThemeSchemes = {
dark: ThemeColors
light: ThemeColors
}
export type KhanegiThemePreset = {
id: Exclude<KhanegiPresetId, 'custom'>
nameFa: string
description: string
borderRadius: { sm: number; md: number; lg: number }
schemes: ThemeSchemes
/** رنگ‌های پیش‌نمایش کارت (حالت dark) */
preview: ThemeColors
}
const navyGoldDark: ThemeColors = {
primary: '#0A1B37',
secondary: '#e3b056',
tertiary: '#ce9d43',
background: '#ffffff',
surface: '#222228',
surfaceHover: '#2c2c34',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.45)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.16)',
borderSubtle: 'rgba(255, 255, 255, 0.12)',
pageBg: '#121216',
pageBgMobile: '#0e0e12',
onAccent: '#0a0a0a',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
}
const navyGoldLight: ThemeColors = {
primary: '#0A1B37',
secondary: '#c9973a',
tertiary: '#b98633',
background: '#faf8f5',
surface: '#ffffff',
surfaceHover: '#fffefb',
textPrimary: '#1c1916',
textSecondary: '#5c564d',
textSubtle: '#8a8278',
border: 'rgba(184, 153, 88, 0.22)',
borderSubtle: 'rgba(0, 0, 0, 0.08)',
pageBg: '#f5f2ed',
pageBgMobile: '#faf8f5',
onAccent: '#ffffff',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
}
const emeraldDark: ThemeColors = {
primary: '#064e3b',
secondary: '#fbbf24',
tertiary: '#d97706',
background: '#ffffff',
surface: '#14201c',
surfaceHover: '#1c2c26',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.5)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.14)',
borderSubtle: 'rgba(255, 255, 255, 0.1)',
pageBg: '#0b1411',
pageBgMobile: '#08100d',
onAccent: '#052e24',
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
}
const emeraldLight: ThemeColors = {
primary: '#065f46',
secondary: '#059669',
tertiary: '#047857',
background: '#ecfdf5',
surface: '#ffffff',
surfaceHover: '#d1fae5',
textPrimary: '#064e3b',
textSecondary: '#047857',
textSubtle: '#6b8578',
border: 'rgba(5, 150, 105, 0.28)',
borderSubtle: 'rgba(6, 95, 70, 0.12)',
pageBg: '#d1fae5',
pageBgMobile: '#ecfdf5',
onAccent: '#ffffff',
success: '#16a34a',
warning: '#f59e0b',
danger: '#dc2626',
}
const warmBronzeDark: ThemeColors = {
primary: '#3f2a1a',
secondary: '#d4a373',
tertiary: '#b08968',
background: '#fffaf5',
surface: '#2a211c',
surfaceHover: '#352a23',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.5)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.14)',
borderSubtle: 'rgba(255, 255, 255, 0.1)',
pageBg: '#16110e',
pageBgMobile: '#120e0b',
onAccent: '#1c1410',
success: '#16a34a',
warning: '#f59e0b',
danger: '#dc2626',
}
const warmBronzeLight: ThemeColors = {
primary: '#7c2d12',
secondary: '#c2410c',
tertiary: '#9a3412',
background: '#fff7ed',
surface: '#ffffff',
surfaceHover: '#ffedd5',
textPrimary: '#7c2d12',
textSecondary: '#9a3412',
textSubtle: '#c2410c',
border: 'rgba(194, 65, 12, 0.28)',
borderSubtle: 'rgba(124, 45, 18, 0.12)',
pageBg: '#ffedd5',
pageBgMobile: '#fff7ed',
onAccent: '#ffffff',
success: '#16a34a',
warning: '#f59e0b',
danger: '#dc2626',
}
export const khanegiThemePresets: KhanegiThemePreset[] = [
{
id: 'navy-gold',
nameFa: 'سرمه‌ای طلایی',
description: 'تم اصلی خانگی — دارک و لایت',
borderRadius: { sm: 8, md: 12, lg: 16 },
schemes: { dark: navyGoldDark, light: navyGoldLight },
preview: navyGoldDark,
},
{
id: 'emerald',
nameFa: 'زمردی طلایی',
description: 'تم سبز تیره با اکسنت طلایی',
borderRadius: { sm: 8, md: 12, lg: 16 },
schemes: { dark: emeraldDark, light: emeraldLight },
preview: emeraldDark,
},
{
id: 'warm-bronze',
nameFa: 'برنز گرم',
description: 'تم برنز/کرم با گوشه‌های گردتر',
borderRadius: { sm: 12, md: 16, lg: 24 },
schemes: { dark: warmBronzeDark, light: warmBronzeLight },
preview: warmBronzeDark,
},
]
export function getKhanegiPreset(id: string): KhanegiThemePreset | undefined {
return khanegiThemePresets.find((p) => p.id === id)
}
/** اعمال preset روی config فعلی؛ pages موجود حفظ می‌شوند */
export function applyKhanegiPreset(config: UiConfig, presetId: Exclude<KhanegiPresetId, 'custom'>): UiConfig {
const preset = getKhanegiPreset(presetId)
if (!preset) return config
const mode = config.app.theme.mode === 'light' ? 'light' : 'dark'
const activeColors = { ...preset.schemes[mode] }
config.app.theme.presetId = presetId
config.app.theme.mode = mode
config.app.theme.colors = activeColors
config.app.theme.schemes = {
dark: { ...preset.schemes.dark },
light: { ...preset.schemes.light },
}
config.app.theme.borderRadius = { ...preset.borderRadius }
return config
}
/** ساخت config تازه از preset (برای نسخه جدید) */
export function createConfigFromPreset(presetId: Exclude<KhanegiPresetId, 'custom'> = 'navy-gold'): UiConfig {
const config = createDefaultKhanegiUiConfig()
return applyKhanegiPreset(config, presetId)
}
export function resolvePresetId(config: UiConfig): KhanegiPresetId {
const id = config.app?.theme?.presetId
if (id === 'navy-gold' || id === 'emerald' || id === 'warm-bronze' || id === 'custom') {
return id
}
return 'custom'
}
export function syncActiveColorsFromMode(config: UiConfig) {
const schemes = config.app.theme.schemes
if (!schemes?.dark || !schemes?.light) return
const mode = config.app.theme.mode === 'light' ? 'light' : 'dark'
Object.assign(config.app.theme.colors, schemes[mode])
}
export function markCustomAndSyncSchemes(config: UiConfig) {
config.app.theme.presetId = 'custom'
const mode = config.app.theme.mode === 'light' ? 'light' : 'dark'
if (!config.app.theme.schemes) {
config.app.theme.schemes = {
dark: { ...config.app.theme.colors },
light: { ...config.app.theme.colors },
}
}
Object.assign(config.app.theme.schemes[mode], config.app.theme.colors)
}
+68 -1
View File
@@ -1,10 +1,15 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { LogoutOutlined, HomeOutlined } from '@ant-design/icons-vue'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/http'
const auth = useAuthStore()
const router = useRouter()
const adminSmsEnabled = ref(true)
const smsToggleLoading = ref(false)
function goHome() {
router.push({ name: 'home' })
@@ -14,6 +19,35 @@ function logout() {
auth.logout()
router.push({ name: 'login' })
}
async function loadSmsToggle() {
try {
const { data } = await api.get('/settings/admin-sms')
adminSmsEnabled.value = data.adminSmsEnabled !== false
} catch {
// keep default on; non-blocking for layout
}
}
async function onSmsToggle(checked: boolean) {
smsToggleLoading.value = true
const previous = adminSmsEnabled.value
adminSmsEnabled.value = checked
try {
const { data } = await api.patch('/settings/admin-sms', { enabled: checked })
adminSmsEnabled.value = data.adminSmsEnabled !== false
message.success(
adminSmsEnabled.value ? 'پیامک هشدار روشن شد' : 'پیامک هشدار خاموش شد',
)
} catch (e: any) {
adminSmsEnabled.value = previous
message.error(e?.response?.data?.message || 'تغییر وضعیت پیامک ناموفق')
} finally {
smsToggleLoading.value = false
}
}
onMounted(loadSmsToggle)
</script>
<template>
@@ -28,6 +62,16 @@ function logout() {
</button>
<div class="topbar-actions">
<div class="sms-toggle" :class="{ off: !adminSmsEnabled }">
<span class="sms-toggle-label">پیامک هشدار</span>
<a-switch
:checked="adminSmsEnabled"
:loading="smsToggleLoading"
checked-children="روشن"
un-checked-children="خاموش"
@change="onSmsToggle"
/>
</div>
<button type="button" class="icon-btn" title="داشبورد" @click="goHome">
<HomeOutlined />
</button>
@@ -107,6 +151,28 @@ function logout() {
gap: 8px;
}
.sms-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: #e8f8f1;
border: 1px solid #b7ebd4;
}
.sms-toggle.off {
background: #fff1f0;
border-color: #ffccc7;
}
.sms-toggle-label {
font-size: 0.8rem;
font-weight: 700;
color: #334155;
white-space: nowrap;
}
.icon-btn {
width: 40px;
height: 40px;
@@ -162,7 +228,8 @@ function logout() {
.content { padding: 24px; }
}
@media (max-width: 520px) {
@media (max-width: 640px) {
.sms-toggle-label { display: none; }
.user-name { display: none; }
}
</style>
+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;
+25
View File
@@ -34,6 +34,26 @@ const router = createRouter({
name: 'server-panel',
component: () => import('@/pages/ServerPanelPage.vue'),
},
{
path: 'servers/:tenantId',
name: 'server-tenant',
component: () => import('@/pages/ServerTenantPage.vue'),
},
{
path: 'plugins',
name: 'plugin-panel',
component: () => import('@/pages/PluginPanelPage.vue'),
},
{
path: 'plugins/:tenantId',
name: 'plugin-tenant',
component: () => import('@/pages/PluginTenantPage.vue'),
},
{
path: 'admins',
name: 'admins',
component: () => import('@/pages/AdminsPage.vue'),
},
{
path: 'products/:product',
name: 'product-versions',
@@ -54,6 +74,11 @@ const router = createRouter({
name: 'version-ui',
component: () => import('@/pages/UiConfigPage.vue'),
},
{
path: 'versions/:id/ui-hamkar',
name: 'version-ui-hamkar',
component: () => import('@/pages/HamkarUiConfigPage.vue'),
},
{
path: 'audit',
name: 'audit',
+5 -3
View File
@@ -4,7 +4,9 @@ import api from '@/api/http'
type User = {
id: string
email: string
mobile: string
firstName?: string
lastName?: string
name: string
role: string
}
@@ -15,8 +17,8 @@ export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = computed(() => Boolean(token.value))
async function login(email: string, password: string) {
const { data } = await api.post('/auth/login', { email, password })
async function login(mobile: string, password: string) {
const { data } = await api.post('/auth/login', { mobile, password })
token.value = data.accessToken
user.value = data.user
localStorage.setItem('sa_token', data.accessToken)
+4
View File
@@ -194,6 +194,8 @@ button, input, textarea, select,
.module-card.sms .icon-wrap { background: linear-gradient(145deg, #9b7cff, #7c5cfc); }
.module-card.server .icon-wrap { background: linear-gradient(145deg, #ff8a8a, #ff6b6b); }
.module-card.plugin .icon-wrap { background: linear-gradient(145deg, #5ec8e8, #2aa8cc); }
.module-card.admins .icon-wrap { background: linear-gradient(145deg, #5b8def, #3b6fd4); }
.module-card.hamkar .icon-wrap { background: linear-gradient(145deg, #3ad4b0, #20c997); }
.module-card.khanegi .icon-wrap { background: linear-gradient(145deg, #ffc86b, #ffb84d); }
@@ -238,6 +240,8 @@ button, input, textarea, select,
.module-card.sms .progress > span { background: #7c5cfc; width: 72%; }
.module-card.server .progress > span { background: #ff6b6b; width: 54%; }
.module-card.plugin .progress > span { background: #2aa8cc; width: 58%; }
.module-card.admins .progress > span { background: #3b6fd4; width: 66%; }
.module-card.hamkar .progress > span { background: #20c997; width: 80%; }
.module-card.khanegi .progress > span { background: #ffb84d; width: 68%; }
+5
View File
@@ -0,0 +1,5 @@
declare module 'vue3-persian-datetime-picker' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>
export default component
}
+63
View File
@@ -0,0 +1,63 @@
/** Iran timezone + Persian (Jalali) calendar helpers for SuperAdmin web. */
export const IRAN_TZ = 'Asia/Tehran'
function toDate(value?: string | Date | number | null): Date | null {
if (value == null || value === '') return null
const d = value instanceof Date ? value : new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
/** تاریخ شمسی به وقت ایران — مثلاً ۱۴۰۵/۰۵/۰۵ */
export function formatIranDate(value?: string | Date | number | null): string {
const d = toDate(value)
if (!d) return '—'
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
timeZone: IRAN_TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d)
}
/** تاریخ و ساعت شمسی به وقت ایران */
export function formatIranDateTime(value?: string | Date | number | null): string {
const d = toDate(value)
if (!d) return '—'
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
timeZone: IRAN_TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(d)
}
/** YYYY-MM-DD (میلادی) برای روز تقویمی ایران */
export function iranYmd(value?: string | Date | number | null): string {
const d = toDate(value) ?? new Date()
return new Intl.DateTimeFormat('en-CA', {
timeZone: IRAN_TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d)
}
/** ISO از تاریخ روز ایران (ظهر محلی تا جابه‌جایی DST کم‌اثر باشد) */
export function iranDateInputToIso(ymd: string | null | undefined): string | null {
if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return null
// Interpret as Iran calendar day noon via fixed offset +03:30 (Iran standard; DST abolished)
const d = new Date(`${ymd}T12:00:00+03:30`)
return Number.isNaN(d.getTime()) ? null : d.toISOString()
}
export function isoToIranDateInput(iso?: string | null): string {
if (!iso) return ''
return iranYmd(iso)
}
export function todayIranDateInput(): string {
return iranYmd(new Date())
}