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