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