Expand SuperAdmin web for SMS, servers, plugins, admins, and UI config.

Align SMS tenant panel with Hamkar, add Jalali dates, and support hamkar/khanegi appearance editing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-30 18:19:54 +03:30
co-authored by Cursor
parent 3371015b96
commit cfee6b0546
27 changed files with 4528 additions and 408 deletions
+180 -2
View File
@@ -11,9 +11,12 @@
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6",
"axios": "^1.18.1",
"dayjs": "^1.11.21",
"jalaliday": "^3.1.1",
"pinia": "^3.0.4",
"vue": "^3.5.18",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"vue3-persian-datetime-picker": "^1.2.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
@@ -1262,6 +1265,12 @@
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz",
@@ -1271,6 +1280,16 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1302,6 +1321,12 @@
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/copy-anything": {
"version": "4.0.5",
"resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz",
@@ -1336,7 +1361,7 @@
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
@@ -1563,6 +1588,12 @@
"node": ">= 6"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
@@ -1624,6 +1655,27 @@
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
@@ -1694,6 +1746,23 @@
"node": ">= 6"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-plain-object": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-3.0.1.tgz",
@@ -1715,6 +1784,21 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/jalaali-js": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz",
"integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A==",
"license": "MIT"
},
"node_modules/jalaliday": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/jalaliday/-/jalaliday-3.1.1.tgz",
"integrity": "sha512-exZzDOfp/32Gj2YkEHNhBBMcltBCTrIe8RXLTfpCvdjYHNfbYSQiNeFVf4RR59FV+2fixUOCEFfzOzyXfgenlg==",
"license": "MIT",
"peerDependencies": {
"dayjs": "^1.11.13"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2058,12 +2142,57 @@
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/moment-jalaali": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/moment-jalaali/-/moment-jalaali-0.9.6.tgz",
"integrity": "sha512-v8wXjQplvk5ez+sUqgsWIrafwIf1BEXXvzTYwsg1wHcqh27nSgKPCJ6FnZRrCz03MoNyB9N31L0oms+vE8Rq7g==",
"license": "MIT",
"dependencies": {
"jalaali-js": "^1.1.0",
"moment": "^2.22.2",
"moment-timezone": "^0.5.21",
"rimraf": "^3.0.2"
}
},
"node_modules/moment-timezone": {
"version": "0.5.48",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
@@ -2101,6 +2230,15 @@
"integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==",
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -2108,6 +2246,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -2203,6 +2350,22 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
@@ -2504,6 +2667,15 @@
"vue": "^3.0.0"
}
},
"node_modules/vue3-persian-datetime-picker": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/vue3-persian-datetime-picker/-/vue3-persian-datetime-picker-1.2.2.tgz",
"integrity": "sha512-d7nkj5vgtUvEXZboSdRmP1uwBfXvXgXqdvsOOMQb34jiMZU/aBDrTYWTEe1N+XKF9pvTTJn8Rws9ttJmyhK/hw==",
"license": "MIT",
"dependencies": {
"moment-jalaali": "^0.9.4"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/warning/-/warning-4.0.3.tgz",
@@ -2512,6 +2684,12 @@
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}
+4 -1
View File
@@ -12,9 +12,12 @@
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6",
"axios": "^1.18.1",
"dayjs": "^1.11.21",
"jalaliday": "^3.1.1",
"pinia": "^3.0.4",
"vue": "^3.5.18",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"vue3-persian-datetime-picker": "^1.2.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed } from 'vue'
import Vue3PersianDatetimePicker from 'vue3-persian-datetime-picker'
import {
iranDateInputToIso,
isoToIranDateInput,
todayIranDateInput,
} from '@/utils/datetime'
const props = withDefaults(
defineProps<{
modelValue?: string | null
/** 'iso' = ISO string for API; 'ymd' = YYYY-MM-DD Gregorian (Iran day) */
valueType?: 'iso' | 'ymd'
placeholder?: string
disabled?: boolean
clearable?: boolean
size?: 'large' | 'middle' | 'small'
}>(),
{
modelValue: null,
valueType: 'iso',
placeholder: 'انتخاب تاریخ',
disabled: false,
clearable: true,
size: 'large',
},
)
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const pickerValue = computed({
get() {
if (!props.modelValue) return ''
if (props.valueType === 'ymd') return props.modelValue
return isoToIranDateInput(props.modelValue)
},
set(val: string | null | undefined) {
const raw = (val || '').trim()
if (!raw) {
emit('update:modelValue', null)
return
}
if (props.valueType === 'ymd') {
emit('update:modelValue', raw)
return
}
emit('update:modelValue', iranDateInputToIso(raw))
},
})
function setToday() {
const ymd = todayIranDateInput()
if (props.valueType === 'ymd') {
emit('update:modelValue', ymd)
} else {
emit('update:modelValue', iranDateInputToIso(ymd))
}
}
</script>
<template>
<div class="jalali-date-field" :class="[`size-${size}`]">
<Vue3PersianDatetimePicker
v-model="pickerValue"
simple
:disabled="disabled"
:clearable="clearable"
format="YYYY-MM-DD"
display-format="jYYYY/jMM/jDD"
:placeholder="placeholder"
input-class="jalali-date-input"
class="jalali-picker"
/>
<button
v-if="!disabled"
type="button"
class="today-btn"
@click="setToday"
>
امروز
</button>
</div>
</template>
<style scoped>
.jalali-date-field {
display: flex;
gap: 8px;
align-items: stretch;
width: 100%;
}
.jalali-date-field :deep(.vpd-input-group),
.jalali-date-field :deep(.jalali-picker) {
flex: 1;
width: 100%;
}
.jalali-date-field :deep(.jalali-date-input),
.jalali-date-field :deep(input) {
width: 100%;
height: 40px;
border: 1px solid #d9d9d9;
border-radius: 8px;
padding: 4px 11px;
font-family: inherit;
font-size: 14px;
direction: ltr;
text-align: left;
background: #fff;
}
.size-large :deep(.jalali-date-input),
.size-large :deep(input) {
height: 48px;
font-size: 16px;
border-radius: 10px;
}
.jalali-date-field :deep(input:focus) {
border-color: #20c997;
outline: none;
box-shadow: 0 0 0 2px rgba(32, 201, 151, 0.15);
}
.today-btn {
appearance: none;
border: 1px solid #d9d9d9;
background: #f8fafc;
border-radius: 10px;
padding: 0 12px;
cursor: pointer;
font-family: inherit;
font-weight: 700;
color: #475569;
white-space: nowrap;
}
.today-btn:hover {
border-color: #20c997;
color: #0f766e;
}
</style>
+139
View File
@@ -0,0 +1,139 @@
export type HamkarPricingBoxTheme = '1' | '2' | '3' | '4'
export type HamkarOrderUi = 'new' | 'old'
export type HamkarPriceFontSize = 'کوچک' | 'متوسط' | 'بزرگ' | 'بزرگ‌تر'
export type HamkarUiConfig = {
kind: 'hamkar'
/**
* When false, GET /get_user will NOT emit ui_settings —
* Laravel settings stay source of truth (prevents visual jumps).
*/
settingsManaged: boolean
/** Named CSS mode from live registry (Montpar, limangold, …) or dynamicMode */
registryMode: string
brand: {
/** Registry `color` → CSS `--bg2` (only when mode=dynamicMode) */
primaryColor: string
/** Registry `secondary_color` → CSS `--colorPrimary` (dynamicMode) */
secondaryColor: string
/** Registry `background_color` → CSS `--bg` (dynamicMode) */
backgroundColor: string
}
settings: {
pricing_box_theme: HamkarPricingBoxTheme
price_font_size: HamkarPriceFontSize
orderUI: HamkarOrderUi
}
}
export const HAMKAR_PRICING_BOX_THEME_OPTIONS: {
value: HamkarPricingBoxTheme
label: string
}[] = [
{ value: '1', label: 'تم ۱ (پیش‌فرض)' },
{ value: '2', label: 'تم ۲' },
{ value: '3', label: 'تم ۳' },
{ value: '4', label: 'تم ۴' },
]
export const HAMKAR_ORDER_UI_OPTIONS: { value: HamkarOrderUi; label: string }[] = [
{ value: 'new', label: 'جدید (کارتی)' },
{ value: 'old', label: 'قدیمی (جدولی)' },
]
export const HAMKAR_PRICE_FONT_SIZE_OPTIONS: {
value: HamkarPriceFontSize
label: string
}[] = [
{ value: 'کوچک', label: 'کوچک' },
{ value: 'متوسط', label: 'متوسط' },
{ value: 'بزرگ', label: 'بزرگ' },
{ value: 'بزرگ‌تر', label: 'بزرگ‌تر' },
]
function asColor(value: unknown): string {
if (value == null) return ''
const s = String(value).trim()
if (!s || s === 'null' || s === 'undefined') return ''
return s
}
export function createDefaultHamkarUiConfig(): HamkarUiConfig {
return {
kind: 'hamkar',
settingsManaged: false,
registryMode: 'dynamicMode',
brand: {
primaryColor: '',
secondaryColor: '',
backgroundColor: '',
},
settings: {
pricing_box_theme: '1',
price_font_size: 'بزرگ',
orderUI: 'old',
},
}
}
export function normalizeHamkarUiConfig(raw: unknown): HamkarUiConfig {
const base = createDefaultHamkarUiConfig()
if (!raw || typeof raw !== 'object') return base
const input = raw as Record<string, any>
const brand = input.brand && typeof input.brand === 'object' ? input.brand : {}
const settings =
input.settings && typeof input.settings === 'object' ? input.settings : {}
const theme = String(settings.pricing_box_theme ?? base.settings.pricing_box_theme)
const orderUI = String(settings.orderUI ?? base.settings.orderUI)
const font = String(settings.price_font_size ?? base.settings.price_font_size)
const registryMode = String(input.registryMode || base.registryMode).trim() || 'dynamicMode'
return {
kind: 'hamkar',
settingsManaged: Boolean(input.settingsManaged),
registryMode,
brand: {
// Do not invent colors — empty means null in registry/get_user
primaryColor: asColor(brand.primaryColor),
secondaryColor: asColor(brand.secondaryColor),
backgroundColor: asColor(brand.backgroundColor),
},
settings: {
pricing_box_theme: (['1', '2', '3', '4'].includes(theme)
? theme
: base.settings.pricing_box_theme) as HamkarPricingBoxTheme,
orderUI: (orderUI === 'old' || orderUI === 'new'
? orderUI
: base.settings.orderUI) as HamkarOrderUi,
price_font_size: (HAMKAR_PRICE_FONT_SIZE_OPTIONS.some((o) => o.value === font)
? font
: base.settings.price_font_size) as HamkarPriceFontSize,
},
}
}
/** Payload for API: empty color strings → null */
export function hamkarUiToApiPayload(config: HamkarUiConfig) {
const normalized = normalizeHamkarUiConfig(config)
const emptyToNull = (v: string) => (v.trim() ? v.trim() : null)
return {
...normalized,
brand: {
primaryColor: emptyToNull(normalized.brand.primaryColor),
secondaryColor: emptyToNull(normalized.brand.secondaryColor),
backgroundColor: emptyToNull(normalized.brand.backgroundColor),
},
}
}
/** Build Laravel-compatible settings overlay for Hamkar user app. */
export function hamkarUiToSettingsList(config: HamkarUiConfig) {
if (!config.settingsManaged) return []
return [
{ key: 'pricing_box_theme', value: config.settings.pricing_box_theme },
{ key: 'price_font_size', value: config.settings.price_font_size },
{ key: 'orderUI', value: config.settings.orderUI },
]
}
+67 -20
View File
@@ -1,3 +1,63 @@
export type ThemeColors = {
primary: string
secondary: string
tertiary: string
background: string
surface: string
surfaceHover: string
textPrimary: string
textSecondary: string
textSubtle: string
border: string
borderSubtle: string
pageBg: string
pageBgMobile: string
onAccent: string
success: string
warning: string
danger: string
}
const navyGoldDark: ThemeColors = {
primary: '#0A1B37',
secondary: '#e3b056',
tertiary: '#ce9d43',
background: '#ffffff',
surface: '#222228',
surfaceHover: '#2c2c34',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.45)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.16)',
borderSubtle: 'rgba(255, 255, 255, 0.12)',
pageBg: '#121216',
pageBgMobile: '#0e0e12',
onAccent: '#0a0a0a',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
}
const navyGoldLight: ThemeColors = {
primary: '#0A1B37',
secondary: '#c9973a',
tertiary: '#b98633',
background: '#faf8f5',
surface: '#ffffff',
surfaceHover: '#fffefb',
textPrimary: '#1c1916',
textSecondary: '#5c564d',
textSubtle: '#8a8278',
border: 'rgba(184, 153, 88, 0.22)',
borderSubtle: 'rgba(0, 0, 0, 0.08)',
pageBg: '#f5f2ed',
pageBgMobile: '#faf8f5',
onAccent: '#ffffff',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
}
/** Default Khanegi app.theme + navigation shell for new UI configs */
export function createDefaultKhanegiUiConfig() {
return {
@@ -10,25 +70,12 @@ export function createDefaultKhanegiUiConfig() {
locale: 'fa-IR',
startRoute: 'home',
theme: {
mode: 'dark',
colors: {
primary: '#0A1B37',
secondary: '#e3b056',
tertiary: '#ce9d43',
background: '#ffffff',
surface: '#222228',
surfaceHover: '#2c2c34',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.45)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.16)',
borderSubtle: 'rgba(255, 255, 255, 0.12)',
pageBg: '#121216',
pageBgMobile: '#0e0e12',
onAccent: '#0a0a0a',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
presetId: 'navy-gold' as string,
mode: 'dark' as 'dark' | 'light' | 'system',
colors: { ...navyGoldDark },
schemes: {
dark: { ...navyGoldDark },
light: { ...navyGoldLight },
},
borderRadius: { sm: 8, md: 12, lg: 16 },
},
@@ -65,7 +112,7 @@ export function createDefaultKhanegiUiConfig() {
brandSubtitle: 'پنل کاربری',
},
},
pages: {},
pages: {} as Record<string, unknown>,
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Mirrors Hamkar user `apply-theme.ts` + default CSS in `main.less` / `maindynamic.less`.
* Named modes (Montpar, limangold, …) do NOT paint registry color fields onto the chrome.
*/
export type HamkarEffectiveTheme = {
mode: string
isDynamic: boolean
/** What paints the top header / mobile menu bar */
headerBg: string
/** Selected menu / text_Secondary / icon accent the user actually sees */
accent: string
/** Ant Design colorPrimary token (buttons) */
antPrimary: string
/** Page / body background */
pageBg: string
/** CSS vars apply-theme writes on :root */
cssVars: {
bg: string
bg2: string
colorPrimary: string
}
note: string
}
const NAMED_DEFAULT = {
headerBg: '#ffffff',
accent: '#2563EB',
antPrimary: '#1d4ed8',
pageBg: '#fafafa',
cssVars: {
bg: '',
bg2: '#ffffff',
colorPrimary: '#1d4ed8',
},
} as const
function hexOr(value: string | null | undefined, fallback: string) {
const s = (value || '').trim()
if (!s || s === 'null' || s === 'undefined') return fallback
return s
}
export function resolveHamkarEffectiveTheme(input: {
registryMode?: string | null
primaryColor?: string | null
secondaryColor?: string | null
backgroundColor?: string | null
}): HamkarEffectiveTheme {
const mode = String(input.registryMode || 'dynamicMode').trim() || 'dynamicMode'
const isDynamic = mode === 'dynamicMode'
if (!isDynamic) {
return {
mode,
isDynamic: false,
...NAMED_DEFAULT,
note: `حالت «${mode}»: هدر و منو از CSS پیش‌فرض همکار می‌آیند؛ فیلدهای color / secondary / bg رجیستری روی ظاهر اعمال نمی‌شوند.`,
}
}
const panel = hexOr(input.primaryColor, '#ffffff')
const accent = hexOr(input.secondaryColor, '#1d4ed8')
const pageBg = hexOr(input.backgroundColor, '#ffffff')
return {
mode,
isDynamic: true,
headerBg: panel,
accent,
antPrimary: accent,
pageBg,
cssVars: {
bg: pageBg,
bg2: panel,
colorPrimary: accent,
},
note: 'حالت dynamicMode: color → هدر (--bg2)، secondary_color → اکسنت (--colorPrimary)، background_color → پس‌زمینه (--bg).',
}
}
+222
View File
@@ -0,0 +1,222 @@
import { createDefaultKhanegiUiConfig, type ThemeColors, type UiConfig } from './defaultKhanegiUiConfig'
export type KhanegiPresetId = 'navy-gold' | 'emerald' | 'warm-bronze' | 'custom'
export type ThemeSchemes = {
dark: ThemeColors
light: ThemeColors
}
export type KhanegiThemePreset = {
id: Exclude<KhanegiPresetId, 'custom'>
nameFa: string
description: string
borderRadius: { sm: number; md: number; lg: number }
schemes: ThemeSchemes
/** رنگ‌های پیش‌نمایش کارت (حالت dark) */
preview: ThemeColors
}
const navyGoldDark: ThemeColors = {
primary: '#0A1B37',
secondary: '#e3b056',
tertiary: '#ce9d43',
background: '#ffffff',
surface: '#222228',
surfaceHover: '#2c2c34',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.45)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.16)',
borderSubtle: 'rgba(255, 255, 255, 0.12)',
pageBg: '#121216',
pageBgMobile: '#0e0e12',
onAccent: '#0a0a0a',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
}
const navyGoldLight: ThemeColors = {
primary: '#0A1B37',
secondary: '#c9973a',
tertiary: '#b98633',
background: '#faf8f5',
surface: '#ffffff',
surfaceHover: '#fffefb',
textPrimary: '#1c1916',
textSecondary: '#5c564d',
textSubtle: '#8a8278',
border: 'rgba(184, 153, 88, 0.22)',
borderSubtle: 'rgba(0, 0, 0, 0.08)',
pageBg: '#f5f2ed',
pageBgMobile: '#faf8f5',
onAccent: '#ffffff',
success: '#16A34A',
warning: '#F59E0B',
danger: '#DC2626',
}
const emeraldDark: ThemeColors = {
primary: '#064e3b',
secondary: '#fbbf24',
tertiary: '#d97706',
background: '#ffffff',
surface: '#14201c',
surfaceHover: '#1c2c26',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.5)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.14)',
borderSubtle: 'rgba(255, 255, 255, 0.1)',
pageBg: '#0b1411',
pageBgMobile: '#08100d',
onAccent: '#052e24',
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
}
const emeraldLight: ThemeColors = {
primary: '#065f46',
secondary: '#059669',
tertiary: '#047857',
background: '#ecfdf5',
surface: '#ffffff',
surfaceHover: '#d1fae5',
textPrimary: '#064e3b',
textSecondary: '#047857',
textSubtle: '#6b8578',
border: 'rgba(5, 150, 105, 0.28)',
borderSubtle: 'rgba(6, 95, 70, 0.12)',
pageBg: '#d1fae5',
pageBgMobile: '#ecfdf5',
onAccent: '#ffffff',
success: '#16a34a',
warning: '#f59e0b',
danger: '#dc2626',
}
const warmBronzeDark: ThemeColors = {
primary: '#3f2a1a',
secondary: '#d4a373',
tertiary: '#b08968',
background: '#fffaf5',
surface: '#2a211c',
surfaceHover: '#352a23',
textPrimary: '#ffffff',
textSecondary: 'rgba(255, 255, 255, 0.5)',
textSubtle: 'rgba(255, 255, 255, 0.35)',
border: 'rgba(255, 255, 255, 0.14)',
borderSubtle: 'rgba(255, 255, 255, 0.1)',
pageBg: '#16110e',
pageBgMobile: '#120e0b',
onAccent: '#1c1410',
success: '#16a34a',
warning: '#f59e0b',
danger: '#dc2626',
}
const warmBronzeLight: ThemeColors = {
primary: '#7c2d12',
secondary: '#c2410c',
tertiary: '#9a3412',
background: '#fff7ed',
surface: '#ffffff',
surfaceHover: '#ffedd5',
textPrimary: '#7c2d12',
textSecondary: '#9a3412',
textSubtle: '#c2410c',
border: 'rgba(194, 65, 12, 0.28)',
borderSubtle: 'rgba(124, 45, 18, 0.12)',
pageBg: '#ffedd5',
pageBgMobile: '#fff7ed',
onAccent: '#ffffff',
success: '#16a34a',
warning: '#f59e0b',
danger: '#dc2626',
}
export const khanegiThemePresets: KhanegiThemePreset[] = [
{
id: 'navy-gold',
nameFa: 'سرمه‌ای طلایی',
description: 'تم اصلی خانگی — دارک و لایت',
borderRadius: { sm: 8, md: 12, lg: 16 },
schemes: { dark: navyGoldDark, light: navyGoldLight },
preview: navyGoldDark,
},
{
id: 'emerald',
nameFa: 'زمردی طلایی',
description: 'تم سبز تیره با اکسنت طلایی',
borderRadius: { sm: 8, md: 12, lg: 16 },
schemes: { dark: emeraldDark, light: emeraldLight },
preview: emeraldDark,
},
{
id: 'warm-bronze',
nameFa: 'برنز گرم',
description: 'تم برنز/کرم با گوشه‌های گردتر',
borderRadius: { sm: 12, md: 16, lg: 24 },
schemes: { dark: warmBronzeDark, light: warmBronzeLight },
preview: warmBronzeDark,
},
]
export function getKhanegiPreset(id: string): KhanegiThemePreset | undefined {
return khanegiThemePresets.find((p) => p.id === id)
}
/** اعمال preset روی config فعلی؛ pages موجود حفظ می‌شوند */
export function applyKhanegiPreset(config: UiConfig, presetId: Exclude<KhanegiPresetId, 'custom'>): UiConfig {
const preset = getKhanegiPreset(presetId)
if (!preset) return config
const mode = config.app.theme.mode === 'light' ? 'light' : 'dark'
const activeColors = { ...preset.schemes[mode] }
config.app.theme.presetId = presetId
config.app.theme.mode = mode
config.app.theme.colors = activeColors
config.app.theme.schemes = {
dark: { ...preset.schemes.dark },
light: { ...preset.schemes.light },
}
config.app.theme.borderRadius = { ...preset.borderRadius }
return config
}
/** ساخت config تازه از preset (برای نسخه جدید) */
export function createConfigFromPreset(presetId: Exclude<KhanegiPresetId, 'custom'> = 'navy-gold'): UiConfig {
const config = createDefaultKhanegiUiConfig()
return applyKhanegiPreset(config, presetId)
}
export function resolvePresetId(config: UiConfig): KhanegiPresetId {
const id = config.app?.theme?.presetId
if (id === 'navy-gold' || id === 'emerald' || id === 'warm-bronze' || id === 'custom') {
return id
}
return 'custom'
}
export function syncActiveColorsFromMode(config: UiConfig) {
const schemes = config.app.theme.schemes
if (!schemes?.dark || !schemes?.light) return
const mode = config.app.theme.mode === 'light' ? 'light' : 'dark'
Object.assign(config.app.theme.colors, schemes[mode])
}
export function markCustomAndSyncSchemes(config: UiConfig) {
config.app.theme.presetId = 'custom'
const mode = config.app.theme.mode === 'light' ? 'light' : 'dark'
if (!config.app.theme.schemes) {
config.app.theme.schemes = {
dark: { ...config.app.theme.colors },
light: { ...config.app.theme.colors },
}
}
Object.assign(config.app.theme.schemes[mode], config.app.theme.colors)
}
+68 -1
View File
@@ -1,10 +1,15 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { LogoutOutlined, HomeOutlined } from '@ant-design/icons-vue'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/http'
const auth = useAuthStore()
const router = useRouter()
const adminSmsEnabled = ref(true)
const smsToggleLoading = ref(false)
function goHome() {
router.push({ name: 'home' })
@@ -14,6 +19,35 @@ function logout() {
auth.logout()
router.push({ name: 'login' })
}
async function loadSmsToggle() {
try {
const { data } = await api.get('/settings/admin-sms')
adminSmsEnabled.value = data.adminSmsEnabled !== false
} catch {
// keep default on; non-blocking for layout
}
}
async function onSmsToggle(checked: boolean) {
smsToggleLoading.value = true
const previous = adminSmsEnabled.value
adminSmsEnabled.value = checked
try {
const { data } = await api.patch('/settings/admin-sms', { enabled: checked })
adminSmsEnabled.value = data.adminSmsEnabled !== false
message.success(
adminSmsEnabled.value ? 'پیامک هشدار روشن شد' : 'پیامک هشدار خاموش شد',
)
} catch (e: any) {
adminSmsEnabled.value = previous
message.error(e?.response?.data?.message || 'تغییر وضعیت پیامک ناموفق')
} finally {
smsToggleLoading.value = false
}
}
onMounted(loadSmsToggle)
</script>
<template>
@@ -28,6 +62,16 @@ function logout() {
</button>
<div class="topbar-actions">
<div class="sms-toggle" :class="{ off: !adminSmsEnabled }">
<span class="sms-toggle-label">پیامک هشدار</span>
<a-switch
:checked="adminSmsEnabled"
:loading="smsToggleLoading"
checked-children="روشن"
un-checked-children="خاموش"
@change="onSmsToggle"
/>
</div>
<button type="button" class="icon-btn" title="داشبورد" @click="goHome">
<HomeOutlined />
</button>
@@ -107,6 +151,28 @@ function logout() {
gap: 8px;
}
.sms-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: #e8f8f1;
border: 1px solid #b7ebd4;
}
.sms-toggle.off {
background: #fff1f0;
border-color: #ffccc7;
}
.sms-toggle-label {
font-size: 0.8rem;
font-weight: 700;
color: #334155;
white-space: nowrap;
}
.icon-btn {
width: 40px;
height: 40px;
@@ -162,7 +228,8 @@ function logout() {
.content { padding: 24px; }
}
@media (max-width: 520px) {
@media (max-width: 640px) {
.sms-toggle-label { display: none; }
.user-name { display: none; }
}
</style>
+271
View File
@@ -0,0 +1,271 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import {
ArrowRightOutlined,
PlusOutlined,
EditOutlined,
DeleteOutlined,
MessageOutlined,
} from '@ant-design/icons-vue'
import api from '@/api/http'
import { useAuthStore } from '@/stores/auth'
type Admin = {
id: string
mobile: string
firstName: string
lastName: string
name?: string
isActive: boolean
role: string
createdAt: string
}
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const saving = ref(false)
const modalOpen = ref(false)
const editingId = ref<string | null>(null)
const rows = ref<Admin[]>([])
const form = reactive({
firstName: '',
lastName: '',
mobile: '',
password: '',
isActive: true,
})
const modalTitle = computed(() => (editingId.value ? 'ویرایش مدیر' : 'افزودن مدیر'))
function fullName(item: Admin) {
return item.name || `${item.firstName} ${item.lastName}`.trim()
}
async function load() {
loading.value = true
try {
const { data } = await api.get<Admin[]>('/admins')
rows.value = data
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در بارگذاری مدیران')
} finally {
loading.value = false
}
}
function openCreate() {
editingId.value = null
form.firstName = ''
form.lastName = ''
form.mobile = ''
form.password = ''
form.isActive = true
modalOpen.value = true
}
function openEdit(item: Admin) {
editingId.value = item.id
form.firstName = item.firstName
form.lastName = item.lastName
form.mobile = item.mobile
form.password = ''
form.isActive = item.isActive
modalOpen.value = true
}
async function save() {
if (!form.firstName.trim() || !form.lastName.trim() || !form.mobile.trim()) {
message.warning('نام، نام خانوادگی و موبایل لازم است')
return
}
if (!editingId.value && form.password.length < 6) {
message.warning('رمز حداقل ۶ کاراکتر باشد')
return
}
if (editingId.value && form.password && form.password.length < 6) {
message.warning('رمز حداقل ۶ کاراکتر باشد')
return
}
saving.value = true
try {
if (editingId.value) {
const payload: Record<string, unknown> = {
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
mobile: form.mobile.trim(),
isActive: form.isActive,
}
if (form.password) payload.password = form.password
await api.patch(`/admins/${editingId.value}`, payload)
message.success('مدیر به‌روزرسانی شد')
} else {
await api.post('/admins', {
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
mobile: form.mobile.trim(),
password: form.password,
})
message.success('مدیر اضافه شد')
}
modalOpen.value = false
await load()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
} finally {
saving.value = false
}
}
function confirmDelete(item: Admin) {
if (item.id === auth.user?.id) {
message.warning('نمی‌توانید خودتان را حذف کنید')
return
}
Modal.confirm({
title: `حذف ${fullName(item)}؟`,
content: 'این عمل قابل بازگشت نیست.',
okText: 'حذف',
okType: 'danger',
cancelText: 'انصراف',
onOk: async () => {
try {
await api.delete(`/admins/${item.id}`)
message.success('حذف شد')
await load()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'حذف ناموفق')
}
},
})
}
async function testSms(item: Admin) {
try {
await api.post(`/admins/${item.id}/test-sms`, {})
message.success(`پیامک تست به ${item.mobile} ارسال شد`)
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ارسال ناموفق')
}
}
onMounted(load)
</script>
<template>
<div class="page-shell">
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
<a-button type="text" @click="router.push({ name: 'home' })">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<h1 class="page-title">مدیران</h1>
<p class="page-sub">
مدیریت کاربران سوپرادمین — ورود با موبایل و رمز. هشدارها به همین شماره‌ها پیامک می‌شود.
</p>
</div>
<a-button type="primary" @click="openCreate">
<PlusOutlined /> افزودن مدیر
</a-button>
</div>
<a-spin :spinning="loading">
<div v-if="rows.length" class="admin-list">
<article v-for="item in rows" :key="item.id" class="admin-card">
<div>
<h3>{{ fullName(item) }}</h3>
<div class="admin-mobile" dir="ltr">{{ item.mobile }}</div>
<div style="margin-top: 8px">
<a-tag :color="item.isActive ? 'success' : 'default'">
{{ item.isActive ? 'فعال' : 'غیرفعال' }}
</a-tag>
<a-tag>{{ item.role }}</a-tag>
</div>
</div>
<div class="admin-actions">
<a-button @click="testSms(item)">
<MessageOutlined /> تست SMS
</a-button>
<a-button type="primary" ghost @click="openEdit(item)">
<EditOutlined /> ویرایش
</a-button>
<a-button danger :disabled="item.id === auth.user?.id" @click="confirmDelete(item)">
<DeleteOutlined />
</a-button>
</div>
</article>
</div>
<div v-else class="hint-box">هنوز مدیری ثبت نشده است.</div>
</a-spin>
<a-modal
v-model:open="modalOpen"
:title="modalTitle"
:confirm-loading="saving"
ok-text="ذخیره"
cancel-text="انصراف"
@ok="save"
>
<a-form layout="vertical" style="margin-top: 12px">
<a-form-item label="نام" required>
<a-input v-model:value="form.firstName" size="large" />
</a-form-item>
<a-form-item label="نام خانوادگی" required>
<a-input v-model:value="form.lastName" size="large" />
</a-form-item>
<a-form-item label="موبایل" required>
<a-input v-model:value="form.mobile" size="large" dir="ltr" placeholder="09xxxxxxxxx" />
</a-form-item>
<a-form-item :label="editingId ? 'رمز جدید (اختیاری)' : 'رمز عبور'" :required="!editingId">
<a-input-password v-model:value="form.password" size="large" />
</a-form-item>
<a-form-item v-if="editingId" label="فعال">
<a-switch v-model:checked="form.isActive" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<style scoped>
.admin-list {
display: grid;
gap: 12px;
}
.admin-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 16px 18px;
border-radius: 18px;
background: #fff;
border: 1px solid #e8eef5;
}
.admin-card h3 {
margin: 0;
font-size: 1.05rem;
}
.admin-mobile {
margin-top: 4px;
color: #64748b;
font-size: 0.92rem;
}
.admin-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>
+3 -2
View File
@@ -2,6 +2,7 @@
import { onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import api from '@/api/http'
import { formatIranDateTime } from '@/utils/datetime'
const loading = ref(false)
const rows = ref<any[]>([])
@@ -29,11 +30,11 @@ onMounted(load)
<a-spin :spinning="loading">
<div v-if="rows.length" class="audit-list" style="margin-top: 14px">
<article v-for="item in rows" :key="item.id" class="audit-item">
<div class="when">{{ new Date(item.createdAt).toLocaleString('fa-IR') }}</div>
<div class="when">{{ formatIranDateTime(item.createdAt) }}</div>
<div class="action">{{ item.action }}</div>
<div style="margin-top: 6px; color: #64748b; font-size: 0.85rem">
نسخه: {{ item.tenant?.nameFa || item.tenantId || '—' }}
· کاربر: {{ item.user?.name || '—' }}
· کاربر: {{ item.user?.name || [item.user?.firstName, item.user?.lastName].filter(Boolean).join(' ') || '—' }}
</div>
</article>
</div>
+469
View File
@@ -0,0 +1,469 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ArrowRightOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
import {
createDefaultHamkarUiConfig,
HAMKAR_ORDER_UI_OPTIONS,
HAMKAR_PRICE_FONT_SIZE_OPTIONS,
HAMKAR_PRICING_BOX_THEME_OPTIONS,
hamkarUiToApiPayload,
normalizeHamkarUiConfig,
type HamkarUiConfig,
} from '@/config/defaultHamkarUiConfig'
import { resolveHamkarEffectiveTheme } from '@/config/hamkarEffectiveTheme'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const saving = ref(false)
const tenantName = ref('')
const domain = ref('')
const config = reactive<HamkarUiConfig>(createDefaultHamkarUiConfig())
const settingsManaged = computed({
get: () => config.settingsManaged,
set: (v: boolean) => {
config.settingsManaged = v
},
})
const themeOptions = computed(() =>
HAMKAR_PRICING_BOX_THEME_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
)
const orderOptions = computed(() =>
HAMKAR_ORDER_UI_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
)
const fontOptions = computed(() =>
HAMKAR_PRICE_FONT_SIZE_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
)
const NAV_ITEMS = ['معامله', 'دارایی', 'سفارشات', 'گزارش‌دهی', 'مشتریان']
function swatchHex(value: string, fallback = '#cbd5e1') {
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value.trim()) ? value.trim() : fallback
}
function textOn(bg: string) {
const hex = swatchHex(bg, '#ffffff').replace('#', '')
const full =
hex.length === 3
? hex
.split('')
.map((c) => c + c)
.join('')
: hex
const n = parseInt(full, 16)
const r = (n >> 16) & 255
const g = (n >> 8) & 255
const b = n & 255
return (r * 299 + g * 587 + b * 114) / 1000 > 128 ? '#0f172a' : '#ffffff'
}
/** Live palette driving the preview (= what save will apply via dynamicMode) */
const palette = computed(() => ({
header: swatchHex(config.brand.primaryColor, '#ffffff'),
accent: swatchHex(config.brand.secondaryColor, '#2563EB'),
page: swatchHex(config.brand.backgroundColor, '#fafafa'),
}))
const previewChrome = computed(() => {
const p = palette.value
const onHeader = textOn(p.header)
return {
shell: {
background: p.page,
borderRadius: '18px',
border: '1px solid #e2e8f0',
overflow: 'hidden' as const,
},
header: {
background: p.header,
color: onHeader,
borderBottom: onHeader === '#ffffff' ? '1px solid rgba(255,255,255,0.12)' : '1px solid #e2e8f0',
},
accent: p.accent,
muted: onHeader === '#ffffff' ? 'rgba(255,255,255,0.72)' : '#616161',
}
})
function seedEditableColors(normalized: HamkarUiConfig) {
const effective = resolveHamkarEffectiveTheme({
registryMode: normalized.registryMode,
primaryColor: normalized.brand.primaryColor,
secondaryColor: normalized.brand.secondaryColor,
backgroundColor: normalized.brand.backgroundColor,
})
// Named CSS modes ignore registry colors — seed pickers with what the app actually paints
if (!effective.isDynamic) {
normalized.brand.primaryColor = effective.headerBg
normalized.brand.secondaryColor = effective.accent
normalized.brand.backgroundColor = effective.pageBg
} else {
if (!normalized.brand.primaryColor) normalized.brand.primaryColor = effective.headerBg
if (!normalized.brand.secondaryColor) normalized.brand.secondaryColor = effective.accent
if (!normalized.brand.backgroundColor) normalized.brand.backgroundColor = effective.pageBg
}
}
async function load() {
loading.value = true
try {
const { data } = await api.get(`/tenants/${route.params.id}`)
if (data.product !== 'hamkar') {
message.warning('این صفحه فقط برای نسخه‌های همکار است')
router.replace({ name: 'version-ui', params: { id: String(route.params.id) } })
return
}
tenantName.value = data.nameFa || ''
domain.value = data.domain || ''
const rawUi =
data.appUiConfig && data.appUiConfig.kind === 'hamkar'
? data.appUiConfig
: {
kind: 'hamkar',
settingsManaged: false,
registryMode:
data.appUiConfig?.registryMode ||
data.notes?.match(/registryMode:([^\s|]+)/)?.[1] ||
'dynamicMode',
brand: {
primaryColor: data.primaryColor,
secondaryColor: data.secondaryColor,
backgroundColor: data.backgroundColor,
},
settings: {},
}
const normalized = normalizeHamkarUiConfig(rawUi)
if (!normalized.brand.primaryColor && data.primaryColor) {
normalized.brand.primaryColor = String(data.primaryColor)
}
if (!normalized.brand.secondaryColor && data.secondaryColor) {
normalized.brand.secondaryColor = String(data.secondaryColor)
}
if (!normalized.brand.backgroundColor && data.backgroundColor) {
normalized.brand.backgroundColor = String(data.backgroundColor)
}
seedEditableColors(normalized)
Object.assign(config, normalized)
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در بارگذاری ظاهر همکار')
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
// Colors only paint the app in dynamicMode (Hamkar apply-theme)
config.registryMode = 'dynamicMode'
const payload = hamkarUiToApiPayload(config)
await api.patch(`/tenants/${route.params.id}`, {
appUiConfig: payload,
primaryColor: payload.brand.primaryColor,
secondaryColor: payload.brand.secondaryColor,
backgroundColor: payload.brand.backgroundColor,
})
Object.assign(config, normalizeHamkarUiConfig(payload))
message.success('ظاهر همکار ذخیره شد')
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="page-shell">
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
<a-button type="text" @click="router.push({ name: 'product-versions', params: { product: 'hamkar' } })">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<h1 class="page-title">ظاهر اپ همکار</h1>
<p class="page-sub">
{{ tenantName || 'نسخه' }}
<template v-if="domain"> · {{ domain }}</template>
</p>
</div>
</div>
<a-spin :spinning="loading">
<div class="layout">
<section class="form-section preview">
<h3>پیش‌نمایش</h3>
<div class="app-preview" :style="previewChrome.shell">
<div class="app-preview-header" :style="previewChrome.header">
<div
v-for="(item, i) in NAV_ITEMS"
:key="item"
class="app-preview-nav"
:style="{ color: i === 0 ? previewChrome.accent : previewChrome.muted }"
>
<span
class="app-preview-dot"
:style="{ background: i === 0 ? previewChrome.accent : previewChrome.muted }"
/>
{{ item }}
</div>
</div>
<div class="app-preview-body">
<div class="app-preview-card">
<div class="app-preview-card-title">خوش آمدگویی</div>
<div class="app-preview-card-line" />
<div class="app-preview-card-line short" />
</div>
<div class="app-preview-price" :style="{ borderColor: palette.accent }">
<span>نقد فردا</span>
<strong :style="{ color: palette.accent }">۸۱٬۴۵۰٬۰۰۰</strong>
</div>
</div>
</div>
</section>
<section class="form-section">
<h3>رنگ‌ها</h3>
<div class="palette-grid">
<label class="palette-card">
<span class="palette-title">هدر</span>
<span class="palette-swatch" :style="{ background: palette.header }">
<input
:value="palette.header"
type="color"
@input="config.brand.primaryColor = ($event.target as HTMLInputElement).value"
/>
</span>
<a-input
v-model:value="config.brand.primaryColor"
dir="ltr"
size="large"
class="palette-hex"
/>
</label>
<label class="palette-card">
<span class="palette-title">اکسنت</span>
<span class="palette-swatch" :style="{ background: palette.accent }">
<input
:value="palette.accent"
type="color"
@input="config.brand.secondaryColor = ($event.target as HTMLInputElement).value"
/>
</span>
<a-input
v-model:value="config.brand.secondaryColor"
dir="ltr"
size="large"
class="palette-hex"
/>
</label>
<label class="palette-card">
<span class="palette-title">پس‌زمینه</span>
<span class="palette-swatch" :style="{ background: palette.page }">
<input
:value="palette.page"
type="color"
@input="config.brand.backgroundColor = ($event.target as HTMLInputElement).value"
/>
</span>
<a-input
v-model:value="config.brand.backgroundColor"
dir="ltr"
size="large"
class="palette-hex"
/>
</label>
</div>
</section>
<section class="form-section">
<h3>تنظیمات UI نسخه</h3>
<a-form-item label="کنترل از سوپرادمین">
<a-switch v-model:checked="settingsManaged" />
</a-form-item>
<a-form layout="vertical" :disabled="!config.settingsManaged">
<a-row :gutter="12">
<a-col :xs="24" :md="8">
<a-form-item label="تم جعبه قیمت">
<a-select
v-model:value="config.settings.pricing_box_theme"
size="large"
:options="themeOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="سایز فونت قیمت">
<a-select
v-model:value="config.settings.price_font_size"
size="large"
:options="fontOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نمایش سفارشات">
<a-select
v-model:value="config.settings.orderUI"
size="large"
:options="orderOptions"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</section>
<div class="sticky-actions">
<a-button type="primary" size="large" block :loading="saving" @click="save">
ذخیره ظاهر همکار
</a-button>
</div>
</div>
</a-spin>
</div>
</template>
<style scoped>
.layout {
display: grid;
gap: 14px;
}
.preview {
padding: 16px;
}
.app-preview {
margin-top: 8px;
}
.app-preview-header {
display: flex;
justify-content: space-evenly;
align-items: center;
gap: 4px;
padding: 10px 8px;
min-height: 56px;
}
.app-preview-nav {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 700;
}
.app-preview-dot {
width: 18px;
height: 18px;
border-radius: 6px;
opacity: 0.9;
}
.app-preview-body {
padding: 14px;
display: grid;
gap: 10px;
}
.app-preview-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px;
}
.app-preview-card-title {
font-weight: 800;
margin-bottom: 8px;
color: #0f172a;
}
.app-preview-card-line {
height: 8px;
border-radius: 999px;
background: #e2e8f0;
margin-bottom: 6px;
}
.app-preview-card-line.short {
width: 55%;
}
.app-preview-price {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
border: 1px solid #e2e8f0;
border-right-width: 4px;
border-radius: 12px;
padding: 12px 14px;
color: #334155;
}
.palette-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 768px) {
.palette-grid {
grid-template-columns: 1fr;
}
}
.palette-card {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #fff;
cursor: pointer;
}
.palette-title {
font-size: 14px;
font-weight: 800;
color: #0f172a;
}
.palette-swatch {
position: relative;
height: 72px;
border-radius: 12px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
overflow: hidden;
}
.palette-swatch input[type='color'] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: 0;
opacity: 0;
cursor: pointer;
}
.palette-hex {
font-family: ui-monospace, monospace;
}
</style>
+142 -9
View File
@@ -4,32 +4,58 @@ import { useRouter } from 'vue-router'
import {
MessageOutlined,
CloudServerOutlined,
AppstoreAddOutlined,
TeamOutlined,
HomeOutlined,
UserOutlined,
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import api from '@/api/http'
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
type SmsSummary = {
balanceRials: number | null
balanceLabel: string
balanceLevel: BalanceLevel
}
type ServerSummary = {
expiryLevel: ExpiryLevel
expiryLabel: string
isDedicated?: boolean
}
type Tenant = {
id: string
product: 'hamkar' | 'khanegi'
slug?: string
nameFa?: string
sms?: SmsSummary
server?: ServerSummary
}
type ExpiringPlugin = {
id: string
tenantId: string
name: string
expiryLevel: ExpiryLevel
expiryLabel: string
tenant: {
id: string
product: 'hamkar' | 'khanegi'
nameFa: string
}
}
const router = useRouter()
const loading = ref(true)
const tenants = ref<Tenant[]>([])
const smsTenants = ref<Tenant[]>([])
const expiringServers = ref<Tenant[]>([])
const expiringPlugins = ref<ExpiringPlugin[]>([])
const hamkarCount = computed(() => tenants.value.filter((t) => t.product === 'hamkar').length)
const khanegiCount = computed(() => tenants.value.filter((t) => t.product === 'khanegi').length)
@@ -37,13 +63,13 @@ const lowestSmsTenants = computed(() => smsTenants.value.slice(0, 5))
const modules = computed(() => [
{
key: 'server',
className: 'server',
title: 'پنل سرور',
desc: 'شارژ، تمدید و پیگیری وضعیت سرورهای نسخه‌های فروشی',
meta: 'جدید',
icon: CloudServerOutlined,
to: { name: 'server-panel' as const },
key: 'admins',
className: 'admins',
title: 'مدیران',
desc: 'افزودن و ویرایش مدیران؛ ورود با موبایل و دریافت هشدار SMS',
meta: 'دسترسی',
icon: UserOutlined,
to: { name: 'admins' as const },
},
{
key: 'hamkar',
@@ -68,12 +94,16 @@ const modules = computed(() => [
async function load() {
loading.value = true
try {
const [tenantsRes, lowestRes] = await Promise.all([
const [tenantsRes, lowestRes, expiringRes, pluginExpiringRes] = await Promise.all([
api.get('/tenants'),
api.get('/sms/lowest', { params: { limit: 5 } }),
api.get('/servers/expiring', { params: { limit: 5 } }),
api.get('/plugins/expiring', { params: { limit: 5 } }),
])
tenants.value = tenantsRes.data
smsTenants.value = lowestRes.data
expiringServers.value = expiringRes.data
expiringPlugins.value = pluginExpiringRes.data
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت آمار')
} finally {
@@ -89,6 +119,22 @@ function openSmsPanel(tenantId?: string) {
router.push({ name: 'sms-panel' })
}
function openServerPanel(tenantId?: string) {
if (tenantId) {
router.push({ name: 'server-tenant', params: { tenantId } })
return
}
router.push({ name: 'server-panel' })
}
function openPluginPanel(tenantId?: string) {
if (tenantId) {
router.push({ name: 'plugin-tenant', params: { tenantId } })
return
}
router.push({ name: 'plugin-panel' })
}
function smsTone(level?: BalanceLevel) {
switch (level) {
case 'healthy':
@@ -102,6 +148,21 @@ function smsTone(level?: BalanceLevel) {
}
}
function serverTone(level?: ExpiryLevel) {
switch (level) {
case 'dedicated':
return 'dedicated'
case 'healthy':
return 'healthy'
case 'warning':
return 'warning'
case 'critical':
return 'critical'
default:
return 'unknown'
}
}
onMounted(load)
</script>
@@ -110,7 +171,7 @@ onMounted(load)
<div class="login-kicker">داشبورد کنترل</div>
<h1 class="page-title">سلام، خوش آمدید</h1>
<p class="page-sub">
از اینجا به پنل پیامکی، پنل سرور و نسخه‌های همکار / خانگی دسترسی دارید.
از اینجا به پنل پیامکی، سرور، افزونه، مدیران و نسخه‌های همکار / خانگی دسترسی دارید.
</p>
<a-spin :spinning="loading">
@@ -149,6 +210,74 @@ onMounted(load)
</div>
</div>
<div class="module-card server sms-dashboard-card" @click="openServerPanel()">
<div>
<div class="icon-wrap">
<CloudServerOutlined />
</div>
<h3>پنل سرور</h3>
<p>نسخه‌هایی که اعتبار سرورشان رو به پایان است</p>
</div>
<div class="sms-mini-grid">
<button
v-for="tenant in expiringServers"
:key="tenant.id"
type="button"
class="sms-mini-card"
:class="serverTone(tenant.server?.expiryLevel)"
@click.stop="openServerPanel(tenant.id)"
>
<strong>{{ tenant.nameFa }}</strong>
<span>{{ tenant.product === 'hamkar' ? 'همکار' : 'خانگی' }}</span>
<small>{{ tenant.server?.expiryLabel || 'نامشخص' }}</small>
</button>
<div v-if="!expiringServers.length" class="sms-mini-empty">
سرور نزدیک‌انقضا یا بدون تاریخ ثبت‌شده نیست
</div>
</div>
<div>
<div class="progress"><span /></div>
<div class="module-meta">
<span>{{ expiringServers.length }} مورد پیگیری</span>
<span>ورود ←</span>
</div>
</div>
</div>
<div class="module-card plugin sms-dashboard-card" @click="openPluginPanel()">
<div>
<div class="icon-wrap">
<AppstoreAddOutlined />
</div>
<h3>پنل افزونه</h3>
<p>افزونه‌هایی که اعتبارشان رو به پایان است (هر نسخه چند افزونه)</p>
</div>
<div class="sms-mini-grid">
<button
v-for="item in expiringPlugins"
:key="item.id"
type="button"
class="sms-mini-card"
:class="serverTone(item.expiryLevel)"
@click.stop="openPluginPanel(item.tenantId)"
>
<strong>{{ item.tenant?.nameFa }}</strong>
<span>{{ item.name }}</span>
<small>{{ item.expiryLabel || 'نامشخص' }}</small>
</button>
<div v-if="!expiringPlugins.length" class="sms-mini-empty">
افزونه نزدیک‌انقضا یا بدون تاریخ ثبت‌شده نیست
</div>
</div>
<div>
<div class="progress"><span /></div>
<div class="module-meta">
<span>{{ expiringPlugins.length }} مورد پیگیری</span>
<span>ورود ←</span>
</div>
</div>
</div>
<button
v-for="item in modules"
:key="item.key"
@@ -223,6 +352,10 @@ onMounted(load)
background: linear-gradient(135deg, #ffd8d8, #ffb9b9);
}
.sms-mini-card.dedicated {
background: linear-gradient(135deg, #e8eef8, #d6e0f5);
}
.sms-mini-card.unknown {
background: linear-gradient(135deg, #eef2f7, #dde5ef);
}
+14 -11
View File
@@ -9,14 +9,14 @@ const router = useRouter()
const loading = ref(false)
const form = reactive({
email: 'admin@abshode.local',
password: 'Admin@12345',
mobile: '',
password: '',
})
async function onSubmit() {
loading.value = true
try {
await auth.login(form.email, form.password)
await auth.login(form.mobile, form.password)
message.success('ورود موفق')
await router.push({ name: 'home' })
} catch (e: any) {
@@ -36,18 +36,21 @@ async function onSubmit() {
<div class="login-kicker">Super Admin · کنترل مرکزی</div>
<div class="brand-mark" style="font-size: 26px">آبشده</div>
<div style="color: #8b95a8; margin: 8px 0 22px; line-height: 1.7">
ورود به داشبورد مدیریت پیامک، سرور و نسخه‌های همکار / خانگی
ورود با شماره موبایل و رمز مدیران
</div>
<a-form :model="form" layout="vertical" @finish="onSubmit">
<a-form-item
label="ایمیل"
name="email"
:rules="[
{ required: true, message: 'ایمیل لازم است' },
{ type: 'string', min: 3, message: 'ایمیل نامعتبر است' },
]"
label="موبایل"
name="mobile"
:rules="[{ required: true, message: 'موبایل لازم است' }]"
>
<a-input v-model:value="form.email" size="large" autocomplete="username" />
<a-input
v-model:value="form.mobile"
size="large"
dir="ltr"
placeholder="09xxxxxxxxx"
autocomplete="username"
/>
</a-form-item>
<a-form-item
label="رمز عبور"
+209
View File
@@ -0,0 +1,209 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ArrowRightOutlined, AppstoreAddOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
type TenantRow = {
id: string
product: 'hamkar' | 'khanegi'
nameFa: string
domain: string
pluginCount: number
summary: {
expiryLevel: ExpiryLevel
expiryLabel: string
}
}
const router = useRouter()
const loadingList = ref(false)
const tenants = ref<TenantRow[]>([])
const groupedTenants = computed(() => ({
hamkar: tenants.value.filter((t) => t.product === 'hamkar'),
khanegi: tenants.value.filter((t) => t.product === 'khanegi'),
}))
function toneClass(level?: ExpiryLevel) {
return level && level !== 'none' ? level : 'unknown'
}
function openTenant(id: string) {
router.push({ name: 'plugin-tenant', params: { tenantId: id } })
}
async function loadTenants() {
loadingList.value = true
try {
const { data } = await api.get('/plugins/tenants')
tenants.value = data
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت افزونه‌ها')
} finally {
loadingList.value = false
}
}
onMounted(loadTenants)
</script>
<template>
<div class="page-shell">
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
<a-button type="text" @click="router.push({ name: 'home' })">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<h1 class="page-title">پنل افزونه</h1>
<p class="page-sub">
اشتراک افزونه‌های نسخه‌ها — هر نسخه می‌تواند چند افزونه داشته باشد یا هیچ‌کدام
</p>
</div>
</div>
<div class="legend">
<span class="chip healthy">اعتبار کافی</span>
<span class="chip warning">نزدیک هشدار</span>
<span class="chip critical">۳ روز یا کمتر / منقضی</span>
<span class="chip unknown">بدون افزونه یا بدون تاریخ</span>
</div>
<section class="sms-selector">
<div class="sms-side-head"><AppstoreAddOutlined /> نسخه‌ها</div>
<a-spin :spinning="loadingList">
<div class="tenant-groups">
<section class="tenant-group">
<div class="tenant-group-head">
<h3>همکار</h3>
<span>{{ groupedTenants.hamkar.length }} نسخه</span>
</div>
<div class="tenant-grid">
<button
v-for="t in groupedTenants.hamkar"
:key="t.id"
type="button"
class="tenant-card"
:class="toneClass(t.summary?.expiryLevel)"
@click="openTenant(t.id)"
>
<strong>{{ t.nameFa }}</strong>
<span>{{ t.summary?.expiryLabel }}</span>
<small>{{ t.pluginCount }} افزونه</small>
</button>
<div v-if="!groupedTenants.hamkar.length" class="empty-hint">نسخه‌ای نیست</div>
</div>
</section>
<section class="tenant-group">
<div class="tenant-group-head">
<h3>خانگی</h3>
<span>{{ groupedTenants.khanegi.length }} نسخه</span>
</div>
<div class="tenant-grid">
<button
v-for="t in groupedTenants.khanegi"
:key="t.id"
type="button"
class="tenant-card"
:class="toneClass(t.summary?.expiryLevel)"
@click="openTenant(t.id)"
>
<strong>{{ t.nameFa }}</strong>
<span>{{ t.summary?.expiryLabel }}</span>
<small>{{ t.pluginCount }} افزونه</small>
</button>
<div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخه‌ای نیست</div>
</div>
</section>
</div>
</a-spin>
</section>
</div>
</template>
<style scoped>
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.chip {
border-radius: 999px;
padding: 6px 12px;
font-size: 0.8rem;
font-weight: 700;
}
.chip.healthy { background: #dff8ea; color: #0f7a45; }
.chip.warning { background: #fff4c7; color: #9a6b00; }
.chip.critical { background: #ffd8d8; color: #b42318; }
.chip.unknown { background: #eef2f7; color: #64748b; }
.sms-selector {
border-radius: 22px;
background: #fff;
border: 1px solid #e8eef5;
padding: 16px;
}
.sms-side-head {
font-weight: 800;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.tenant-groups { display: grid; gap: 18px; }
.tenant-group-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.tenant-group-head h3 { margin: 0; font-size: 1rem; }
.tenant-group-head span { color: #94a3b8; font-size: 0.85rem; }
.tenant-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
}
.tenant-card {
appearance: none;
border: none;
border-radius: 16px;
padding: 14px;
text-align: right;
cursor: pointer;
font-family: inherit;
display: grid;
gap: 4px;
color: #17324d;
}
.tenant-card strong { font-size: 0.95rem; }
.tenant-card span,
.tenant-card small { opacity: 0.92; font-size: 0.82rem; }
.tenant-card.healthy { background: linear-gradient(135deg, #dff8ea, #c3efd7); }
.tenant-card.warning { background: linear-gradient(135deg, #fff4c7, #ffe39a); }
.tenant-card.critical { background: linear-gradient(135deg, #ffd8d8, #ffb9b9); }
.tenant-card.unknown { background: linear-gradient(135deg, #eef2f7, #dde5ef); }
.empty-hint {
grid-column: 1 / -1;
color: #94a3b8;
padding: 12px;
font-size: 0.85rem;
}
</style>
+619
View File
@@ -0,0 +1,619 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
type PluginItem = {
id: string
name: string
specs: string | null
description: string | null
priceRials: number | null
chargedAt: string | null
planMonths: number | null
expiresAt: string | null
alertDaysBefore: number
isActive: boolean
summary: {
expiryLevel: ExpiryLevel
expiryLabel: string
planLabel: string | null
expiresAt: string | null
}
charges: any[]
}
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const renewing = ref(false)
const creating = ref(false)
const selectedId = ref<string | null>(null)
const showCreate = ref(false)
const tenant = ref<{
id: string
product: 'hamkar' | 'khanegi'
slug: string
nameFa: string
domain: string
} | null>(null)
const plugins = ref<PluginItem[]>([])
const planOptions = ref<{ months: number; label: string }[]>([])
const form = reactive({
name: '',
specs: '',
description: '',
priceRials: 0 as number,
chargedAt: null as string | null,
planMonths: 1 as number,
alertDaysBefore: 3,
isActive: true,
})
const createForm = reactive({
name: '',
specs: '',
description: '',
priceRials: 0 as number,
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
planMonths: 1,
alertDaysBefore: 3,
})
const renewForm = reactive({
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
planMonths: 1,
priceRials: 0 as number,
note: '',
})
const tenantId = computed(() => String(route.params.tenantId || ''))
const selected = computed(() => plugins.value.find((p) => p.id === selectedId.value) || null)
const planSelectOptions = computed(() =>
(planOptions.value.length
? planOptions.value
: [
{ months: 1, label: 'یک‌ماهه' },
{ months: 3, label: 'سه‌ماهه' },
{ months: 6, label: 'شش‌ماهه' },
{ months: 12, label: 'یک‌ساله' },
]
).map((p) => ({ value: p.months, label: p.label })),
)
function toneClass(level?: ExpiryLevel) {
return level && level !== 'none' ? level : 'unknown'
}
function formatMoney(value: number | null | undefined) {
if (value == null) return '—'
return `${new Intl.NumberFormat('fa-IR').format(value)} ریال`
}
function fillForm(plugin: PluginItem) {
Object.assign(form, {
name: plugin.name || '',
specs: plugin.specs || '',
description: plugin.description || '',
priceRials: plugin.priceRials ?? 0,
chargedAt: plugin.chargedAt || null,
planMonths: plugin.planMonths || 1,
alertDaysBefore: plugin.alertDaysBefore ?? 3,
isActive: plugin.isActive !== false,
})
renewForm.planMonths = plugin.planMonths || 1
renewForm.priceRials = plugin.priceRials ?? 0
renewForm.chargedAt = iranDateInputToIso(todayIranDateInput())
renewForm.note = ''
}
function selectPlugin(id: string) {
selectedId.value = id
const plugin = plugins.value.find((p) => p.id === id)
if (plugin) fillForm(plugin)
}
async function loadDetail() {
if (!tenantId.value) return
loading.value = true
try {
const { data } = await api.get(`/plugins/tenants/${tenantId.value}`)
tenant.value = data.tenant
plugins.value = data.plugins || []
planOptions.value = data.planOptions || []
const keep =
selectedId.value && plugins.value.some((p) => p.id === selectedId.value)
? selectedId.value
: plugins.value[0]?.id || null
selectedId.value = keep
if (keep) {
const plugin = plugins.value.find((p) => p.id === keep)
if (plugin) fillForm(plugin)
}
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت افزونه‌ها')
} finally {
loading.value = false
}
}
async function createPlugin() {
if (!createForm.name.trim()) {
message.warning('نام افزونه لازم است')
return
}
creating.value = true
try {
const { data } = await api.post(`/plugins/tenants/${tenantId.value}`, {
name: createForm.name.trim(),
specs: createForm.specs || undefined,
description: createForm.description || undefined,
priceRials: Number(createForm.priceRials) || 0,
chargedAt: createForm.chargedAt || undefined,
planMonths: Number(createForm.planMonths),
alertDaysBefore: Number(createForm.alertDaysBefore) || 3,
})
message.success('افزونه اضافه شد')
showCreate.value = false
createForm.name = ''
createForm.specs = ''
createForm.description = ''
createForm.priceRials = 0
createForm.chargedAt = iranDateInputToIso(todayIranDateInput())
createForm.planMonths = 1
createForm.alertDaysBefore = 3
await loadDetail()
if (data?.plugin?.id) selectPlugin(data.plugin.id)
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ثبت ناموفق')
} finally {
creating.value = false
}
}
async function savePlugin() {
if (!selectedId.value) return
if (!form.name.trim()) {
message.warning('نام افزونه لازم است')
return
}
saving.value = true
try {
await api.patch(`/plugins/items/${selectedId.value}`, {
name: form.name.trim(),
specs: form.specs || null,
description: form.description || null,
priceRials: Number(form.priceRials) || 0,
chargedAt: form.chargedAt,
planMonths: Number(form.planMonths),
alertDaysBefore: Number(form.alertDaysBefore) || 3,
isActive: form.isActive,
})
message.success('ذخیره شد')
await loadDetail()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
} finally {
saving.value = false
}
}
async function renewPlugin() {
if (!selectedId.value) return
if (!renewForm.chargedAt) {
message.warning('تاریخ شارژ لازم است')
return
}
renewing.value = true
try {
await api.post(`/plugins/items/${selectedId.value}/renew`, {
chargedAt: renewForm.chargedAt,
planMonths: Number(renewForm.planMonths),
priceRials: Number(renewForm.priceRials),
note: renewForm.note || undefined,
})
message.success('افزونه شارژ/تمدید شد')
renewForm.note = ''
await loadDetail()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'شارژ ناموفق')
} finally {
renewing.value = false
}
}
function confirmDelete() {
if (!selected.value) return
Modal.confirm({
title: 'حذف افزونه',
content: `افزونه «${selected.value.name}» و تاریخچه شارژ آن حذف شود؟`,
okText: 'حذف',
okType: 'danger',
cancelText: 'انصراف',
onOk: async () => {
try {
await api.delete(`/plugins/items/${selected.value!.id}`)
message.success('حذف شد')
selectedId.value = null
await loadDetail()
} catch (e: any) {
message.error(e?.response?.data?.message || 'حذف ناموفق')
}
},
})
}
watch(() => route.params.tenantId, loadDetail)
onMounted(loadDetail)
</script>
<template>
<div class="page-shell">
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
<a-button type="text" @click="router.push({ name: 'plugin-panel' })">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<h1 class="page-title">{{ tenant?.nameFa || 'افزونه‌های نسخه' }}</h1>
<p class="page-sub">
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} · {{ tenant?.domain }}
</p>
</div>
<a-button type="primary" @click="showCreate = true">
<PlusOutlined /> افزونه جدید
</a-button>
</div>
<a-spin :spinning="loading">
<div class="layout">
<aside class="plugin-list">
<div class="list-head">افزونه‌ها ({{ plugins.length }})</div>
<button
v-for="p in plugins"
:key="p.id"
type="button"
class="plugin-card"
:class="[toneClass(p.summary?.expiryLevel), { active: p.id === selectedId, inactive: !p.isActive }]"
@click="selectPlugin(p.id)"
>
<strong>{{ p.name }}</strong>
<span>{{ p.summary?.expiryLabel }}</span>
<small v-if="!p.isActive">غیرفعال</small>
</button>
<div v-if="!plugins.length" class="empty-hint">
هنوز افزونه‌ای ثبت نشده — با «افزونه جدید» شروع کنید.
</div>
</aside>
<div v-if="selected" class="detail">
<div class="status-banner" :class="toneClass(selected.summary?.expiryLevel)">
<strong>{{ selected.name }} — {{ selected.summary?.expiryLabel }}</strong>
<span v-if="selected.summary?.expiresAt">
انقضا: {{ formatIranDate(selected.summary.expiresAt) }}
<template v-if="selected.summary?.planLabel"> · {{ selected.summary.planLabel }}</template>
</span>
</div>
<section class="form-section">
<div class="section-head">
<h3>مشخصات افزونه</h3>
<a-button danger type="text" @click="confirmDelete">
<DeleteOutlined /> حذف
</a-button>
</div>
<a-form layout="vertical">
<a-form-item label="نام افزونه" required>
<a-input v-model:value="form.name" size="large" placeholder="مثلاً ماژول حسابداری / پنل طلا" />
</a-form-item>
<a-form-item label="مشخصات">
<a-textarea v-model:value="form.specs" :rows="2" placeholder="جزئیات فنی یا یادداشت داخلی…" />
</a-form-item>
<a-form-item label="توضیحات">
<a-textarea v-model:value="form.description" :rows="2" />
</a-form-item>
<a-form-item label="فعال">
<a-switch v-model:checked="form.isActive" />
</a-form-item>
</a-form>
</section>
<section class="form-section">
<h3>اعتبار و قیمت (وضعیت فعلی)</h3>
<a-form layout="vertical">
<a-row :gutter="12">
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ شارژ (شمسی)">
<JalaliDatePicker v-model="form.chargedAt" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مدت شارژ">
<a-select
v-model:value="form.planMonths"
size="large"
:options="planSelectOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="قیمت دوره (ریال)">
<a-input-number
v-model:value="form.priceRials"
style="width: 100%"
size="large"
:min="0"
:step="100000"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="شروع هشدار SMS (روز مانده به انقضا)">
<a-input-number
v-model:value="form.alertDaysBefore"
:min="1"
:max="30"
size="large"
style="width: 160px"
/>
<span class="inline-hint">پیش‌فرض ۳ روز — روزانه یک پیام تا تمدید</span>
</a-form-item>
</a-form>
</section>
<div class="sticky-actions settings-save">
<a-button type="primary" size="large" block :loading="saving" @click="savePlugin">
ذخیره تنظیمات
</a-button>
</div>
<section class="form-section renew-section">
<h3>شارژ / تمدید جدید</h3>
<p class="field-hint">
با ثبت شارژ، تاریخ انقضا تمدید می‌شود و هشدارهای قبلی قطع می‌گردد تا دوباره نزدیک پایان برسد.
</p>
<a-form layout="vertical">
<a-row :gutter="12">
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ شروع دوره (شمسی)">
<JalaliDatePicker v-model="renewForm.chargedAt" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مدت">
<a-select
v-model:value="renewForm.planMonths"
size="large"
:options="planSelectOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ (ریال)">
<a-input-number
v-model:value="renewForm.priceRials"
style="width: 100%"
size="large"
:min="0"
:step="100000"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="یادداشت">
<a-input v-model:value="renewForm.note" />
</a-form-item>
<a-button type="primary" size="large" :loading="renewing" @click="renewPlugin">
ثبت شارژ / تمدید
</a-button>
</a-form>
</section>
<section class="form-section">
<h3>تاریخچه شارژ</h3>
<div v-if="selected.charges?.length" class="charge-list">
<article v-for="item in selected.charges" :key="item.id" class="charge-item">
<div>
<strong>{{ formatMoney(item.priceRials) }}</strong>
<span> · {{ item.planMonths }} ماهه</span>
</div>
<div class="charge-meta">
از {{ formatIranDate(item.chargedAt) }} تا {{ formatIranDate(item.expiresAt) }}
<template v-if="item.note"> · {{ item.note }}</template>
</div>
</article>
</div>
<div v-else class="hint-box">هنوز شارژی ثبت نشده است.</div>
</section>
</div>
<div v-else class="detail empty-detail">
افزونه‌ای انتخاب نشده است.
</div>
</div>
</a-spin>
<a-modal
v-model:open="showCreate"
title="افزودن افزونه"
ok-text="ثبت"
cancel-text="انصراف"
:confirm-loading="creating"
@ok="createPlugin"
>
<a-form layout="vertical" style="margin-top: 12px">
<a-form-item label="نام افزونه" required>
<a-input v-model:value="createForm.name" size="large" placeholder="نام افزونه" />
</a-form-item>
<a-form-item label="مشخصات">
<a-textarea v-model:value="createForm.specs" :rows="2" />
</a-form-item>
<a-row :gutter="12">
<a-col :span="12">
<a-form-item label="تاریخ شارژ">
<JalaliDatePicker v-model="createForm.chargedAt" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="مدت">
<a-select
v-model:value="createForm.planMonths"
size="large"
:options="planSelectOptions"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="قیمت دوره (ریال)">
<a-input-number
v-model:value="createForm.priceRials"
style="width: 100%"
size="large"
:min="0"
:step="100000"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<style scoped>
.layout {
display: grid;
grid-template-columns: minmax(200px, 260px) 1fr;
gap: 16px;
align-items: start;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
}
.plugin-list {
border-radius: 18px;
background: #fff;
border: 1px solid #e8eef5;
padding: 12px;
display: grid;
gap: 8px;
}
.list-head {
font-weight: 800;
margin-bottom: 4px;
color: #334155;
}
.plugin-card {
appearance: none;
border: 2px solid transparent;
border-radius: 14px;
padding: 12px;
text-align: right;
cursor: pointer;
font-family: inherit;
display: grid;
gap: 2px;
color: #17324d;
}
.plugin-card.active {
border-color: #3b6fd4;
}
.plugin-card.inactive {
opacity: 0.65;
}
.plugin-card.healthy { background: linear-gradient(135deg, #dff8ea, #c3efd7); }
.plugin-card.warning { background: linear-gradient(135deg, #fff4c7, #ffe39a); }
.plugin-card.critical { background: linear-gradient(135deg, #ffd8d8, #ffb9b9); }
.plugin-card.unknown { background: linear-gradient(135deg, #eef2f7, #dde5ef); }
.empty-hint,
.empty-detail,
.hint-box {
color: #94a3b8;
padding: 14px;
font-size: 0.9rem;
}
.detail {
min-width: 0;
}
.status-banner {
border-radius: 18px;
padding: 14px 16px;
margin-bottom: 14px;
display: grid;
gap: 4px;
}
.status-banner.healthy { background: #dff8ea; color: #0f7a45; }
.status-banner.warning { background: #fff4c7; color: #9a6b00; }
.status-banner.critical { background: #ffd8d8; color: #b42318; }
.status-banner.unknown { background: #eef2f7; color: #64748b; }
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.field-hint {
margin: 0 0 8px;
color: #64748b;
font-size: 13px;
line-height: 1.7;
}
.settings-save {
margin-bottom: 8px;
}
.renew-section {
margin-top: 28px;
padding-top: 22px;
}
.inline-hint {
margin-right: 10px;
color: #94a3b8;
font-size: 12px;
}
.charge-list {
display: grid;
gap: 8px;
}
.charge-item {
border-radius: 14px;
background: #f8fafc;
padding: 12px 14px;
}
.charge-meta {
margin-top: 4px;
color: #64748b;
font-size: 0.85rem;
}
</style>
+6 -2
View File
@@ -152,11 +152,15 @@ onMounted(load)
مدیریت
</a-button>
<a-button
v-if="product === 'khanegi'"
type="default"
block
style="flex: 1"
@click="router.push({ name: 'version-ui', params: { id: item.id } })"
@click="
router.push({
name: product === 'hamkar' ? 'version-ui-hamkar' : 'version-ui',
params: { id: item.id },
})
"
>
ظاهر اپ
</a-button>
+207 -13
View File
@@ -1,8 +1,57 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ArrowRightOutlined, CloudServerOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
type TenantRow = {
id: string
product: 'hamkar' | 'khanegi'
slug: string
nameFa: string
domain: string
server: {
isDedicated: boolean
expiryLevel: ExpiryLevel
expiryLabel: string
serverName?: string | null
planLabel?: string | null
}
}
const router = useRouter()
const loadingList = ref(false)
const tenants = ref<TenantRow[]>([])
const groupedTenants = computed(() => ({
hamkar: tenants.value.filter((t) => t.product === 'hamkar'),
khanegi: tenants.value.filter((t) => t.product === 'khanegi'),
}))
function toneClass(level?: ExpiryLevel) {
return level || 'unknown'
}
function openTenant(id: string) {
router.push({ name: 'server-tenant', params: { tenantId: id } })
}
async function loadTenants() {
loadingList.value = true
try {
const { data } = await api.get('/servers/tenants')
tenants.value = data
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت سرورها')
} finally {
loadingList.value = false
}
}
onMounted(loadTenants)
</script>
<template>
@@ -11,24 +60,169 @@ const router = useRouter()
<a-button type="text" @click="router.push({ name: 'home' })">
<ArrowRightOutlined />
</a-button>
<div>
<div style="flex: 1">
<h1 class="page-title">پنل سرور</h1>
<p class="page-sub">شارژ و تمدید سرورهای نسخه‌های فروشی</p>
<p class="page-sub">وضعیت اختصاصی یا مدیریت‌شده، شارژ و هشدار نزدیک پایان اعتبار</p>
</div>
</div>
<div class="placeholder-hero server">
<div style="display: flex; align-items: center; gap: 12px; font-size: 28px">
<CloudServerOutlined />
<strong style="font-size: 1.2rem">به‌زودی</strong>
</div>
<p style="margin: 12px 0 0; opacity: 0.92; line-height: 1.8">
این بخش برای پیگیری شارژ، تمدید و وضعیت سرورها طراحی می‌شود — مشابه ساختار پنل پیامکی.
</p>
<div class="legend">
<span class="chip dedicated">اختصاصی</span>
<span class="chip healthy">اعتبار کافی — سبز</span>
<span class="chip warning">نزدیک هشدار — زرد</span>
<span class="chip critical">۳ روز یا کمتر / منقضی — قرمز</span>
<span class="chip unknown">بدون تاریخ</span>
</div>
<div class="hint-box">
اسکلت صفحه آماده است. جزئیات业务流程 و اتصال به سرویس‌ها را در مرحله بعد اضافه می‌کنیم.
</div>
<section class="sms-selector">
<div class="sms-side-head"><CloudServerOutlined /> نسخه‌ها</div>
<a-spin :spinning="loadingList">
<div class="tenant-groups">
<section class="tenant-group">
<div class="tenant-group-head">
<h3>همکار</h3>
<span>{{ groupedTenants.hamkar.length }} نسخه</span>
</div>
<div class="tenant-grid">
<button
v-for="t in groupedTenants.hamkar"
:key="t.id"
type="button"
class="tenant-card"
:class="toneClass(t.server?.expiryLevel)"
@click="openTenant(t.id)"
>
<strong>{{ t.nameFa }}</strong>
<span>{{ t.server?.expiryLabel }}</span>
<small v-if="t.server?.serverName">{{ t.server.serverName }}</small>
<small v-else>{{ t.domain }}</small>
</button>
<div v-if="!groupedTenants.hamkar.length" class="empty-hint">نسخه‌ای نیست</div>
</div>
</section>
<section class="tenant-group">
<div class="tenant-group-head">
<h3>خانگی</h3>
<span>{{ groupedTenants.khanegi.length }} نسخه</span>
</div>
<div class="tenant-grid">
<button
v-for="t in groupedTenants.khanegi"
:key="t.id"
type="button"
class="tenant-card"
:class="toneClass(t.server?.expiryLevel)"
@click="openTenant(t.id)"
>
<strong>{{ t.nameFa }}</strong>
<span>{{ t.server?.expiryLabel }}</span>
<small v-if="t.server?.serverName">{{ t.server.serverName }}</small>
<small v-else>{{ t.domain }}</small>
</button>
<div v-if="!groupedTenants.khanegi.length" class="empty-hint">نسخه‌ای نیست</div>
</div>
</section>
</div>
</a-spin>
</section>
</div>
</template>
<style scoped>
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.chip {
border-radius: 999px;
padding: 6px 12px;
font-size: 0.8rem;
font-weight: 700;
}
.chip.dedicated { background: #e8eef8; color: #3b5bdb; }
.chip.healthy { background: #dff8ea; color: #0f7a45; }
.chip.warning { background: #fff4c7; color: #9a6b00; }
.chip.critical { background: #ffd8d8; color: #b42318; }
.chip.unknown { background: #eef2f7; color: #64748b; }
.sms-selector {
border-radius: 22px;
background: #fff;
border: 1px solid #e8eef5;
padding: 16px;
}
.sms-side-head {
font-weight: 800;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.tenant-groups {
display: grid;
gap: 18px;
}
.tenant-group-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.tenant-group-head h3 {
margin: 0;
font-size: 1rem;
}
.tenant-group-head span {
color: #94a3b8;
font-size: 0.85rem;
}
.tenant-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
}
.tenant-card {
appearance: none;
border: none;
border-radius: 16px;
padding: 14px;
text-align: right;
cursor: pointer;
font-family: inherit;
display: grid;
gap: 4px;
color: #17324d;
}
.tenant-card strong { font-size: 0.95rem; }
.tenant-card span,
.tenant-card small {
opacity: 0.92;
font-size: 0.82rem;
}
.tenant-card.dedicated { background: linear-gradient(135deg, #e8eef8, #d6e0f5); }
.tenant-card.healthy { background: linear-gradient(135deg, #dff8ea, #c3efd7); }
.tenant-card.warning { background: linear-gradient(135deg, #fff4c7, #ffe39a); }
.tenant-card.critical { background: linear-gradient(135deg, #ffd8d8, #ffb9b9); }
.tenant-card.unknown { background: linear-gradient(135deg, #eef2f7, #dde5ef); }
.empty-hint {
grid-column: 1 / -1;
color: #94a3b8;
padding: 12px;
font-size: 0.85rem;
}
</style>
+379
View File
@@ -0,0 +1,379 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ArrowRightOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'
type Summary = {
isDedicated: boolean
expiryLevel: ExpiryLevel
expiryLabel: string
daysLeft: number | null
planLabel: string | null
expiresAt: string | null
}
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const renewing = ref(false)
const tenant = ref<{
id: string
product: 'hamkar' | 'khanegi'
slug: string
nameFa: string
domain: string
} | null>(null)
const summary = ref<Summary | null>(null)
const charges = ref<any[]>([])
const planOptions = ref<{ months: number; label: string }[]>([])
const form = reactive({
isDedicated: false,
serverName: '',
specs: '',
priceRials: 0 as number,
chargedAt: null as string | null,
planMonths: 1 as number,
description: '',
alertDaysBefore: 3,
})
const renewForm = reactive({
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
planMonths: 1,
priceRials: 0 as number,
note: '',
})
const tenantId = computed(() => String(route.params.tenantId || ''))
const planSelectOptions = computed(() =>
(planOptions.value.length
? planOptions.value
: [
{ months: 1, label: 'یک‌ماهه' },
{ months: 3, label: 'سه‌ماهه' },
{ months: 6, label: 'شش‌ماهه' },
{ months: 12, label: 'یک‌ساله' },
]
).map((p) => ({ value: p.months, label: p.label })),
)
function toneClass(level?: ExpiryLevel) {
return level || 'unknown'
}
function formatMoney(value: number | null | undefined) {
if (value == null) return '—'
return `${new Intl.NumberFormat('fa-IR').format(value)} ریال`
}
async function loadDetail() {
if (!tenantId.value) return
loading.value = true
try {
const { data } = await api.get(`/servers/tenants/${tenantId.value}`)
tenant.value = data.tenant
summary.value = data.summary
charges.value = data.charges || []
planOptions.value = data.planOptions || []
Object.assign(form, {
isDedicated: Boolean(data.account.isDedicated),
serverName: data.account.serverName || '',
specs: data.account.specs || '',
priceRials: data.account.priceRials ?? 0,
chargedAt: data.account.chargedAt || null,
planMonths: data.account.planMonths || 1,
description: data.account.description || '',
alertDaysBefore: data.account.alertDaysBefore ?? 3,
})
renewForm.planMonths = data.account.planMonths || 1
renewForm.priceRials = data.account.priceRials ?? 0
renewForm.chargedAt = iranDateInputToIso(todayIranDateInput())
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت سرور')
} finally {
loading.value = false
}
}
async function saveAccount() {
saving.value = true
try {
await api.patch(`/servers/tenants/${tenantId.value}`, {
isDedicated: form.isDedicated,
serverName: form.serverName || null,
specs: form.specs || null,
priceRials: Number(form.priceRials) || 0,
chargedAt: form.isDedicated ? undefined : form.chargedAt,
planMonths: form.isDedicated ? undefined : Number(form.planMonths),
description: form.description || null,
alertDaysBefore: Number(form.alertDaysBefore) || 3,
})
message.success('ذخیره شد')
await loadDetail()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
} finally {
saving.value = false
}
}
async function renewServer() {
if (form.isDedicated) {
message.warning('برای شارژ مدیریت‌شده، ابتدا سوئیچ اختصاصی را خاموش کنید')
return
}
if (!renewForm.chargedAt) {
message.warning('تاریخ شارژ لازم است')
return
}
renewing.value = true
try {
await api.post(`/servers/tenants/${tenantId.value}/renew`, {
chargedAt: renewForm.chargedAt,
planMonths: Number(renewForm.planMonths),
priceRials: Number(renewForm.priceRials),
note: renewForm.note || undefined,
})
message.success('سرور شارژ/تمدید شد')
renewForm.note = ''
await loadDetail()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'شارژ ناموفق')
} finally {
renewing.value = false
}
}
watch(() => route.params.tenantId, loadDetail)
onMounted(loadDetail)
</script>
<template>
<div class="page-shell">
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
<a-button type="text" @click="router.push({ name: 'server-panel' })">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<h1 class="page-title">{{ tenant?.nameFa || 'سرور نسخه' }}</h1>
<p class="page-sub">
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} · {{ tenant?.domain }}
</p>
</div>
</div>
<a-spin :spinning="loading">
<div v-if="summary" class="status-banner" :class="toneClass(summary.expiryLevel)">
<strong>{{ summary.expiryLabel }}</strong>
<span v-if="summary.expiresAt && !summary.isDedicated">
انقضا: {{ formatIranDate(summary.expiresAt) }}
<template v-if="summary.planLabel"> · {{ summary.planLabel }}</template>
</span>
</div>
<section class="form-section">
<h3>نوع سرور</h3>
<a-form-item label="سرور اختصاصی (مدیریت و شارژ با مشتری)">
<a-switch v-model:checked="form.isDedicated" />
</a-form-item>
<p class="field-hint">
اگر روشن باشد فقط ثبت می‌شود که سرور اختصاصی است و هشدار/شارژ از طرف ما لازم نیست.
اگر خاموش باشد، مشخصات و دوره اعتبار را اینجا مدیریت کنید.
</p>
</section>
<section class="form-section">
<h3>مشخصات</h3>
<a-form layout="vertical">
<a-form-item label="نام سرور">
<a-input v-model:value="form.serverName" size="large" placeholder="مثلاً Hetzner CX31 / ایران‌سرور" />
</a-form-item>
<a-form-item label="مشخصات">
<a-textarea
v-model:value="form.specs"
:rows="3"
placeholder="CPU / RAM / دیسک / IP / دیتاسنتر…"
/>
</a-form-item>
<a-form-item label="توضیحات">
<a-textarea v-model:value="form.description" :rows="3" />
</a-form-item>
</a-form>
</section>
<section v-if="!form.isDedicated" class="form-section">
<h3>اعتبار و قیمت (وضعیت فعلی)</h3>
<a-form layout="vertical">
<a-row :gutter="12">
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ شارژ (شمسی)">
<JalaliDatePicker v-model="form.chargedAt" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مدت شارژ">
<a-select
v-model:value="form.planMonths"
size="large"
:options="planSelectOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="قیمت دوره (ریال)">
<a-input-number
v-model:value="form.priceRials"
style="width: 100%"
size="large"
:min="0"
:step="100000"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="شروع هشدار SMS (روز مانده به انقضا)">
<a-input-number
v-model:value="form.alertDaysBefore"
:min="1"
:max="30"
size="large"
style="width: 160px"
/>
<span class="inline-hint">پیش‌فرض ۳ روز — روزانه یک پیام تا تمدید</span>
</a-form-item>
</a-form>
</section>
<div class="sticky-actions settings-save">
<a-button type="primary" size="large" block :loading="saving" @click="saveAccount">
ذخیره تنظیمات
</a-button>
</div>
<section v-if="!form.isDedicated" class="form-section renew-section">
<h3>شارژ / تمدید جدید</h3>
<p class="field-hint">
با ثبت شارژ، تاریخ انقضا تمدید می‌شود و هشدارهای قبلی قطع می‌گردد تا دوباره نزدیک پایان برسد.
</p>
<a-form layout="vertical">
<a-row :gutter="12">
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ شروع دوره (شمسی)">
<JalaliDatePicker v-model="renewForm.chargedAt" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مدت">
<a-select
v-model:value="renewForm.planMonths"
size="large"
:options="planSelectOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ (ریال)">
<a-input-number
v-model:value="renewForm.priceRials"
style="width: 100%"
size="large"
:min="0"
:step="100000"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="یادداشت">
<a-input v-model:value="renewForm.note" />
</a-form-item>
<a-button type="primary" size="large" :loading="renewing" @click="renewServer">
ثبت شارژ / تمدید
</a-button>
</a-form>
</section>
<section v-if="!form.isDedicated" class="form-section">
<h3>تاریخچه شارژ</h3>
<div v-if="charges.length" class="charge-list">
<article v-for="item in charges" :key="item.id" class="charge-item">
<div>
<strong>{{ formatMoney(item.priceRials) }}</strong>
<span> · {{ item.planMonths }} ماهه</span>
</div>
<div class="charge-meta">
از {{ formatIranDate(item.chargedAt) }} تا {{ formatIranDate(item.expiresAt) }}
<template v-if="item.note"> · {{ item.note }}</template>
</div>
</article>
</div>
<div v-else class="hint-box">هنوز شارژی ثبت نشده است.</div>
</section>
</a-spin>
</div>
</template>
<style scoped>
.status-banner {
border-radius: 18px;
padding: 14px 16px;
margin-bottom: 14px;
display: grid;
gap: 4px;
}
.status-banner.dedicated { background: #e8eef8; color: #3b5bdb; }
.status-banner.healthy { background: #dff8ea; color: #0f7a45; }
.status-banner.warning { background: #fff4c7; color: #9a6b00; }
.status-banner.critical { background: #ffd8d8; color: #b42318; }
.status-banner.unknown { background: #eef2f7; color: #64748b; }
.field-hint {
margin: 0 0 8px;
color: #64748b;
font-size: 13px;
line-height: 1.7;
}
.settings-save {
margin-bottom: 8px;
}
.renew-section {
margin-top: 28px;
padding-top: 22px;
}
.inline-hint {
margin-right: 10px;
color: #94a3b8;
font-size: 12px;
}
.charge-list {
display: grid;
gap: 8px;
}
.charge-item {
border-radius: 14px;
background: #f8fafc;
padding: 12px 14px;
}
.charge-meta {
margin-top: 4px;
color: #64748b;
font-size: 0.85rem;
}
</style>
File diff suppressed because it is too large Load Diff
+104 -31
View File
@@ -2,13 +2,14 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ArrowRightOutlined } from '@ant-design/icons-vue'
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const uploading = ref(false)
const isEdit = computed(() => Boolean(route.params.id))
@@ -19,9 +20,7 @@ const form = reactive({
nameEn: '',
domain: '',
avatarUrl: '',
primaryColor: '#1d4ed8',
secondaryColor: '#0f172a',
backgroundColor: '#ffffff',
appVersion: '1',
nodeServer: '',
nodeWs: '',
appUrl: '',
@@ -44,9 +43,6 @@ async function load() {
if (!isEdit.value) {
const q = String(route.query.product || '')
form.product = q === 'khanegi' ? 'khanegi' : 'hamkar'
if (form.product === 'khanegi') {
form.primaryColor = '#b45309'
}
return
}
@@ -60,9 +56,7 @@ async function load() {
nameEn: data.nameEn || '',
domain: data.domain,
avatarUrl: data.avatarUrl || '',
primaryColor: data.primaryColor || '#1d4ed8',
secondaryColor: data.secondaryColor || '#0f172a',
backgroundColor: data.backgroundColor || '#ffffff',
appVersion: data.appVersion || '1',
nodeServer: data.nodeServer,
nodeWs: data.nodeWs || '',
appUrl: data.appUrl || '',
@@ -78,6 +72,35 @@ async function load() {
}
}
async function uploadLogo(options: {
file: string | Blob | File
onSuccess?: (body?: unknown) => void
onError?: (e: Error) => void
}) {
const file = options.file
if (!(file instanceof File) && !(file instanceof Blob)) return
uploading.value = true
try {
const body = new FormData()
body.append('file', file)
const { data } = await api.post<{ url: string }>('/tenants/upload-avatar', body)
form.avatarUrl = data.url
options.onSuccess?.(data)
message.success('لوگو آپلود شد')
} catch (e: any) {
const msg = e?.response?.data?.message
options.onError?.(e instanceof Error ? e : new Error(String(msg || 'upload failed')))
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'آپلود ناموفق')
} finally {
uploading.value = false
}
}
function clearLogo() {
form.avatarUrl = ''
}
async function save() {
saving.value = true
try {
@@ -85,7 +108,6 @@ async function save() {
if (!payload.remoteApiToken) {
delete (payload as any).remoteApiToken
}
// node_ws فقط برای همکار حیاتی است؛ برای خانگی اختیاری (SSE)
if (isEdit.value) {
await api.patch(`/tenants/${route.params.id}`, payload)
message.success('به‌روزرسانی شد')
@@ -150,27 +172,34 @@ onMounted(load)
</section>
<section class="form-section">
<h3>برند و ظاهر</h3>
<a-form-item label="لوگو (URL)" name="avatarUrl">
<a-input v-model:value="form.avatarUrl" size="large" dir="ltr" placeholder="https://..." />
<h3>لوگو</h3>
<p class="field-hint">
از طریق /get_user به‌عنوان <code dir="ltr">avatar</code> به نسخه‌ها فرستاده می‌شود.
<template v-if="form.product === 'khanegi'"> رنگ و تم از صفحه «ظاهر اپ» تنظیم می‌شود.</template>
</p>
<a-form-item name="avatarUrl">
<div class="logo-upload-row">
<a-upload
name="file"
list-type="picture-card"
:show-upload-list="false"
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml"
:custom-request="uploadLogo"
:disabled="uploading"
>
<div v-if="form.avatarUrl" class="logo-preview">
<img :src="form.avatarUrl" alt="لوگو" />
</div>
<div v-else class="logo-placeholder">
<PlusOutlined />
<div>{{ uploading ? 'در حال آپلود…' : 'آپلود لوگو' }}</div>
</div>
</a-upload>
<a-button v-if="form.avatarUrl" danger @click="clearLogo">
<DeleteOutlined /> حذف لوگو
</a-button>
</div>
</a-form-item>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item label="اصلی">
<a-input v-model:value="form.primaryColor" type="color" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="ثانویه">
<a-input v-model:value="form.secondaryColor" type="color" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="پس‌زمینه">
<a-input v-model:value="form.backgroundColor" type="color" />
</a-form-item>
</a-col>
</a-row>
</section>
<section class="form-section">
@@ -200,6 +229,17 @@ onMounted(load)
<a-form-item label="فعال در رجیستری">
<a-switch v-model:checked="form.isActive" />
</a-form-item>
<a-form-item label="ورژن اپ" name="appVersion">
<a-input
v-model:value="form.appVersion"
size="large"
dir="ltr"
placeholder="مثلاً 2.0.1"
/>
<template #extra>
از طریق <code dir="ltr">/get_version</code> به اپ فرستاده می‌شود. با تغییرش، کلاینت کش کانفیگ را رفرش و صفحه را reload می‌کند.
</template>
</a-form-item>
<a-form-item label="یادداشت داخلی">
<a-textarea v-model:value="form.notes" :rows="3" />
</a-form-item>
@@ -215,3 +255,36 @@ onMounted(load)
<a-spin />
</div>
</template>
<style scoped>
.field-hint {
margin: 0 0 12px;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.logo-upload-row {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.logo-preview,
.logo-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
}
.logo-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
</style>
+282 -83
View File
@@ -1,11 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { ArrowRightOutlined } from '@ant-design/icons-vue'
import api from '@/api/http'
import { createDefaultKhanegiUiConfig } from '@/config/defaultKhanegiUiConfig'
import { colorFieldLabels, colorPalettes } from '@/theme/tokens'
import {
applyKhanegiPreset,
khanegiThemePresets,
markCustomAndSyncSchemes,
resolvePresetId,
syncActiveColorsFromMode,
type KhanegiPresetId,
} from '@/config/khanegiThemePresets'
import { colorFieldLabels } from '@/theme/tokens'
const route = useRoute()
const router = useRouter()
@@ -16,28 +24,63 @@ const product = ref<'hamkar' | 'khanegi'>('khanegi')
const activeTab = ref('theme')
const advancedJson = ref('')
const jsonError = ref('')
const selectedPreset = ref<KhanegiPresetId>('navy-gold')
const previewMode = ref<'dark' | 'light'>('dark')
const config = reactive(createDefaultKhanegiUiConfig())
const isCustom = computed(() => selectedPreset.value === 'custom')
const activePreviewColors = computed(() => {
const schemes = config.app.theme.schemes
if (schemes?.[previewMode.value]) return schemes[previewMode.value]
return config.app.theme.colors
})
const colorKeys = computed(() => Object.keys(config.app.theme.colors))
const previewStyle = computed(() => ({
background: config.app.theme.colors.pageBg,
color: config.app.theme.colors.textPrimary,
background: activePreviewColors.value.pageBg,
color: activePreviewColors.value.textPrimary,
borderRadius: `${config.app.theme.borderRadius.lg}px`,
}))
const accentStyle = computed(() => ({
background: config.app.theme.colors.secondary,
color: config.app.theme.colors.onAccent,
background: activePreviewColors.value.secondary,
color: activePreviewColors.value.onAccent,
borderRadius: `${config.app.theme.borderRadius.md}px`,
}))
function applyPalette(id: string) {
const palette = colorPalettes.find((p) => p.id === id)
if (!palette) return
Object.assign(config.app.theme.colors, palette.colors)
message.success(`پالت «${palette.name}» اعمال شد`)
function selectPreset(id: KhanegiPresetId) {
selectedPreset.value = id
if (id === 'custom') {
config.app.theme.presetId = 'custom'
if (!config.app.theme.schemes) {
config.app.theme.schemes = {
dark: { ...config.app.theme.colors },
light: { ...config.app.theme.colors },
}
}
message.info('حالت سفارشی — می‌توانید جزئیات را ویرایش کنید')
return
}
applyKhanegiPreset(config, id)
previewMode.value = config.app.theme.mode === 'light' ? 'light' : 'dark'
syncAdvancedJson()
message.success(`تم «${khanegiThemePresets.find((p) => p.id === id)?.nameFa}» اعمال شد`)
}
function onModeChange() {
syncActiveColorsFromMode(config)
previewMode.value = config.app.theme.mode === 'light' ? 'light' : 'dark'
if (isCustom.value) markCustomAndSyncSchemes(config)
}
function onColorEdit() {
if (!isCustom.value) {
selectedPreset.value = 'custom'
}
markCustomAndSyncSchemes(config)
}
function syncAdvancedJson() {
@@ -48,7 +91,9 @@ function syncAdvancedJson() {
function applyAdvancedJson() {
try {
const parsed = JSON.parse(advancedJson.value)
Object.assign(config, parsed)
Object.assign(config, createDefaultKhanegiUiConfig(), parsed)
ensureSchemes()
selectedPreset.value = resolvePresetId(config)
jsonError.value = ''
message.success('JSON اعمال شد')
} catch (e: any) {
@@ -56,6 +101,18 @@ function applyAdvancedJson() {
}
}
function ensureSchemes() {
if (!config.app.theme.schemes?.dark || !config.app.theme.schemes?.light) {
config.app.theme.schemes = {
dark: { ...config.app.theme.colors },
light: { ...config.app.theme.colors },
}
}
if (!config.app.theme.presetId) {
config.app.theme.presetId = 'custom'
}
}
async function load() {
loading.value = true
try {
@@ -63,7 +120,11 @@ async function load() {
tenantName.value = data.nameFa
product.value = data.product
if (data.product !== 'khanegi') {
message.warning('ظاهر اپ فعلاً فقط برای خانگی فعال است')
if (data.product === 'hamkar') {
router.replace({ name: 'version-ui-hamkar', params: { id: String(route.params.id) } })
return
}
message.warning('ظاهر اپ برای این محصول فعال نیست')
router.replace({ name: 'version-edit', params: { id: String(route.params.id) } })
return
}
@@ -73,6 +134,9 @@ async function load() {
Object.assign(config, createDefaultKhanegiUiConfig())
}
}
ensureSchemes()
selectedPreset.value = resolvePresetId(config)
previewMode.value = config.app.theme.mode === 'light' ? 'light' : 'dark'
syncAdvancedJson()
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در بارگذاری')
@@ -84,6 +148,12 @@ async function load() {
async function save() {
saving.value = true
try {
ensureSchemes()
config.app.theme.presetId = selectedPreset.value
syncActiveColorsFromMode(config)
if (selectedPreset.value === 'custom') {
markCustomAndSyncSchemes(config)
}
const colors = config.app.theme.colors
await api.patch(`/tenants/${route.params.id}`, {
appUiConfig: JSON.parse(JSON.stringify(config)),
@@ -101,6 +171,13 @@ async function save() {
}
}
watch(
() => config.app.theme.mode,
() => {
if (!loading.value) onModeChange()
},
)
onMounted(load)
</script>
@@ -112,20 +189,66 @@ onMounted(load)
</a-button>
<div style="flex: 1">
<h1 class="page-title">ظاهر اپ خانگی</h1>
<p class="page-sub">{{ tenantName }} · app_ui_config</p>
<p class="page-sub">{{ tenantName }} · ۳ تم آماده + سفارشی</p>
</div>
<a-button type="primary" size="large" :loading="saving" @click="save">ذخیره ظاهر</a-button>
</div>
<section class="form-section preset-section">
<h3>انتخاب تم</h3>
<div class="preset-grid">
<button
v-for="p in khanegiThemePresets"
:key="p.id"
type="button"
class="preset-card"
:class="{ active: selectedPreset === p.id }"
@click="selectPreset(p.id)"
>
<div class="preset-swatches">
<span :style="{ background: p.preview.primary }" />
<span :style="{ background: p.preview.secondary }" />
<span :style="{ background: p.preview.pageBg }" />
<span :style="{ background: p.preview.surface }" />
</div>
<div class="preset-name">{{ p.nameFa }}</div>
<div class="preset-desc">{{ p.description }}</div>
</button>
<button
type="button"
class="preset-card custom-card"
:class="{ active: selectedPreset === 'custom' }"
@click="selectPreset('custom')"
>
<div class="preset-swatches custom-swatches">
<span /><span /><span /><span />
</div>
<div class="preset-name">سفارشی</div>
<div class="preset-desc">شخصی‌سازی رنگ و جزئیات</div>
</button>
</div>
</section>
<div class="preview-phone" :style="previewStyle">
<div class="preview-bar">
<span>{{ tenantName || 'پیش‌نمایش' }}</span>
<div class="preview-modes">
<button type="button" :class="{ on: previewMode === 'dark' }" @click="previewMode = 'dark'">تیره</button>
<button type="button" :class="{ on: previewMode === 'light' }" @click="previewMode = 'light'">روشن</button>
</div>
<span class="preview-chip" :style="accentStyle">اکسنت</span>
</div>
<div class="preview-card" :style="{ background: config.app.theme.colors.surface, borderRadius: config.app.theme.borderRadius.md + 'px' }">
<div
class="preview-card"
:style="{
background: activePreviewColors.surface,
borderRadius: config.app.theme.borderRadius.md + 'px',
color: activePreviewColors.textPrimary,
}"
>
<div style="opacity: 0.7; font-size: 12px">surface</div>
<div style="margin-top: 8px; font-weight: 700">نمونه کارت اپ</div>
<div style="margin-top: 6px; font-size: 12px" :style="{ color: config.app.theme.colors.textSecondary }">
<div style="margin-top: 6px; font-size: 12px" :style="{ color: activePreviewColors.textSecondary }">
متن فرعی با رنگ theme
</div>
</div>
@@ -133,29 +256,16 @@ onMounted(load)
<a-tabs v-model:activeKey="activeTab" class="ui-tabs">
<a-tab-pane key="theme" tab="تم و رنگ‌ها">
<section class="form-section">
<h3>پالت‌های آماده</h3>
<div class="palette-grid">
<button
v-for="p in colorPalettes"
:key="p.id"
type="button"
class="palette-card"
@click="applyPalette(p.id)"
>
<div class="palette-swatches">
<span :style="{ background: p.colors.primary }" />
<span :style="{ background: p.colors.secondary }" />
<span :style="{ background: p.colors.pageBg }" />
<span :style="{ background: p.colors.surface }" />
</div>
<div class="palette-name">{{ p.name }}</div>
</button>
</div>
</section>
<a-alert
v-if="!isCustom"
type="info"
show-icon
style="margin-bottom: 16px"
message="برای ویرایش دستی رنگ‌ها، «سفارشی» را انتخاب کنید یا یکی از فیلدها را تغییر دهید."
/>
<section class="form-section">
<h3>حالت تم</h3>
<h3>حالت پیش‌فرض اپ</h3>
<a-radio-group v-model:value="config.app.theme.mode" button-style="solid">
<a-radio-button value="dark">تیره</a-radio-button>
<a-radio-button value="light">روشن</a-radio-button>
@@ -163,7 +273,7 @@ onMounted(load)
</section>
<section class="form-section">
<h3>رنگ‌ها</h3>
<h3>رنگ‌ها {{ isCustom ? '(سفارشی)' : '(فقط‌خواندنی تا انتخاب سفارشی)' }}</h3>
<div class="color-grid">
<label v-for="key in colorKeys" :key="key" class="color-field">
<span>{{ colorFieldLabels[key] || key }}</span>
@@ -177,8 +287,15 @@ onMounted(load)
v-model="(config.app.theme.colors as any)[key]"
type="color"
class="color-native"
:disabled="!isCustom"
@change="onColorEdit"
/>
<a-input
v-model:value="(config.app.theme.colors as any)[key]"
dir="ltr"
:disabled="!isCustom"
@change="onColorEdit"
/>
<a-input v-model:value="(config.app.theme.colors as any)[key]" dir="ltr" />
</div>
</label>
</div>
@@ -189,17 +306,38 @@ onMounted(load)
<a-row :gutter="12">
<a-col :span="8">
<a-form-item label="sm">
<a-input-number v-model:value="config.app.theme.borderRadius.sm" :min="0" :max="40" style="width: 100%" />
<a-input-number
v-model:value="config.app.theme.borderRadius.sm"
:min="0"
:max="40"
style="width: 100%"
:disabled="!isCustom"
@change="onColorEdit"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="md">
<a-input-number v-model:value="config.app.theme.borderRadius.md" :min="0" :max="40" style="width: 100%" />
<a-input-number
v-model:value="config.app.theme.borderRadius.md"
:min="0"
:max="40"
style="width: 100%"
:disabled="!isCustom"
@change="onColorEdit"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="lg">
<a-input-number v-model:value="config.app.theme.borderRadius.lg" :min="0" :max="48" style="width: 100%" />
<a-input-number
v-model:value="config.app.theme.borderRadius.lg"
:min="0"
:max="48"
style="width: 100%"
:disabled="!isCustom"
@change="onColorEdit"
/>
</a-form-item>
</a-col>
</a-row>
@@ -257,7 +395,7 @@ onMounted(load)
<a-tab-pane key="json" tab="JSON پیشرفته">
<p class="page-sub" style="margin-bottom: 12px">
برای pages و جزئیات کامل می‌توانید JSON را مستقیم ویرایش کنید. اگر از قبل در دیتابیس مشتری pages دارید، اینجا پیست کنید.
برای pages و جزئیات کامل می‌توانید JSON را مستقیم ویرایش کنید.
</p>
<a-textarea v-model:value="advancedJson" :rows="18" dir="ltr" class="json-box" />
<div v-if="jsonError" style="color: #dc2626; margin-top: 8px">{{ jsonError }}</div>
@@ -285,6 +423,74 @@ onMounted(load)
margin-bottom: 16px;
}
.preset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.preset-card {
border: 2px solid var(--line);
border-radius: 16px;
padding: 12px;
background: #fff;
cursor: pointer;
text-align: right;
font-family: inherit;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.preset-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
.preset-card.active {
border-color: var(--sa-primary, #20c997);
box-shadow: 0 0 0 3px rgba(32, 201, 151, 0.18);
}
.preset-swatches {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
margin-bottom: 8px;
}
.preset-swatches span {
height: 22px;
border-radius: 6px;
}
.custom-swatches span {
background: linear-gradient(135deg, #e2e8f0, #94a3b8);
}
.custom-swatches span:nth-child(2) {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
}
.custom-swatches span:nth-child(3) {
background: linear-gradient(135deg, #cbd5e1, #64748b);
}
.custom-swatches span:nth-child(4) {
background: linear-gradient(135deg, #1e293b, #0f172a);
}
.preset-name {
font-size: 0.9rem;
font-weight: 700;
color: var(--ink);
}
.preset-desc {
margin-top: 4px;
font-size: 0.75rem;
color: var(--ink-soft);
line-height: 1.4;
}
.preview-phone {
padding: 16px;
margin-bottom: 18px;
@@ -296,8 +502,36 @@ onMounted(load)
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-weight: 700;
flex-wrap: wrap;
}
.preview-modes {
display: inline-flex;
gap: 4px;
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
padding: 2px;
}
.preview-modes button {
border: none;
background: transparent;
color: inherit;
opacity: 0.65;
font-family: inherit;
font-size: 12px;
padding: 4px 10px;
border-radius: 8px;
cursor: pointer;
}
.preview-modes button.on {
opacity: 1;
background: rgba(255, 255, 255, 0.16);
font-weight: 700;
}
.preview-chip {
@@ -310,46 +544,6 @@ onMounted(load)
padding: 14px;
}
.palette-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
}
.palette-card {
border: 1px solid var(--line);
border-radius: 14px;
padding: 10px;
background: #fff;
cursor: pointer;
text-align: right;
font-family: inherit;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.palette-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
.palette-swatches {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
margin-bottom: 8px;
}
.palette-swatches span {
height: 22px;
border-radius: 6px;
}
.palette-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--ink);
}
.color-grid {
display: grid;
gap: 12px;
@@ -383,6 +577,11 @@ onMounted(load)
cursor: pointer;
}
.color-native:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.color-swatch {
width: 44px;
height: 44px;
+25
View File
@@ -34,6 +34,26 @@ const router = createRouter({
name: 'server-panel',
component: () => import('@/pages/ServerPanelPage.vue'),
},
{
path: 'servers/:tenantId',
name: 'server-tenant',
component: () => import('@/pages/ServerTenantPage.vue'),
},
{
path: 'plugins',
name: 'plugin-panel',
component: () => import('@/pages/PluginPanelPage.vue'),
},
{
path: 'plugins/:tenantId',
name: 'plugin-tenant',
component: () => import('@/pages/PluginTenantPage.vue'),
},
{
path: 'admins',
name: 'admins',
component: () => import('@/pages/AdminsPage.vue'),
},
{
path: 'products/:product',
name: 'product-versions',
@@ -54,6 +74,11 @@ const router = createRouter({
name: 'version-ui',
component: () => import('@/pages/UiConfigPage.vue'),
},
{
path: 'versions/:id/ui-hamkar',
name: 'version-ui-hamkar',
component: () => import('@/pages/HamkarUiConfigPage.vue'),
},
{
path: 'audit',
name: 'audit',
+5 -3
View File
@@ -4,7 +4,9 @@ import api from '@/api/http'
type User = {
id: string
email: string
mobile: string
firstName?: string
lastName?: string
name: string
role: string
}
@@ -15,8 +17,8 @@ export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = computed(() => Boolean(token.value))
async function login(email: string, password: string) {
const { data } = await api.post('/auth/login', { email, password })
async function login(mobile: string, password: string) {
const { data } = await api.post('/auth/login', { mobile, password })
token.value = data.accessToken
user.value = data.user
localStorage.setItem('sa_token', data.accessToken)
+4
View File
@@ -194,6 +194,8 @@ button, input, textarea, select,
.module-card.sms .icon-wrap { background: linear-gradient(145deg, #9b7cff, #7c5cfc); }
.module-card.server .icon-wrap { background: linear-gradient(145deg, #ff8a8a, #ff6b6b); }
.module-card.plugin .icon-wrap { background: linear-gradient(145deg, #5ec8e8, #2aa8cc); }
.module-card.admins .icon-wrap { background: linear-gradient(145deg, #5b8def, #3b6fd4); }
.module-card.hamkar .icon-wrap { background: linear-gradient(145deg, #3ad4b0, #20c997); }
.module-card.khanegi .icon-wrap { background: linear-gradient(145deg, #ffc86b, #ffb84d); }
@@ -238,6 +240,8 @@ button, input, textarea, select,
.module-card.sms .progress > span { background: #7c5cfc; width: 72%; }
.module-card.server .progress > span { background: #ff6b6b; width: 54%; }
.module-card.plugin .progress > span { background: #2aa8cc; width: 58%; }
.module-card.admins .progress > span { background: #3b6fd4; width: 66%; }
.module-card.hamkar .progress > span { background: #20c997; width: 80%; }
.module-card.khanegi .progress > span { background: #ffb84d; width: 68%; }
+5
View File
@@ -0,0 +1,5 @@
declare module 'vue3-persian-datetime-picker' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>
export default component
}
+63
View File
@@ -0,0 +1,63 @@
/** Iran timezone + Persian (Jalali) calendar helpers for SuperAdmin web. */
export const IRAN_TZ = 'Asia/Tehran'
function toDate(value?: string | Date | number | null): Date | null {
if (value == null || value === '') return null
const d = value instanceof Date ? value : new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
/** تاریخ شمسی به وقت ایران — مثلاً ۱۴۰۵/۰۵/۰۵ */
export function formatIranDate(value?: string | Date | number | null): string {
const d = toDate(value)
if (!d) return '—'
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
timeZone: IRAN_TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d)
}
/** تاریخ و ساعت شمسی به وقت ایران */
export function formatIranDateTime(value?: string | Date | number | null): string {
const d = toDate(value)
if (!d) return '—'
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
timeZone: IRAN_TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(d)
}
/** YYYY-MM-DD (میلادی) برای روز تقویمی ایران */
export function iranYmd(value?: string | Date | number | null): string {
const d = toDate(value) ?? new Date()
return new Intl.DateTimeFormat('en-CA', {
timeZone: IRAN_TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d)
}
/** ISO از تاریخ روز ایران (ظهر محلی تا جابه‌جایی DST کم‌اثر باشد) */
export function iranDateInputToIso(ymd: string | null | undefined): string | null {
if (!ymd || !/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return null
// Interpret as Iran calendar day noon via fixed offset +03:30 (Iran standard; DST abolished)
const d = new Date(`${ymd}T12:00:00+03:30`)
return Number.isNaN(d.getTime()) ? null : d.toISOString()
}
export function isoToIranDateInput(iso?: string | null): string {
if (!iso) return ''
return iranYmd(iso)
}
export function todayIranDateInput(): string {
return iranYmd(new Date())
}
+8
View File
@@ -20,6 +20,14 @@ export default defineConfig({
target: 'http://localhost:3001',
changeOrigin: true,
},
'/get_version': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
})