Initial SuperAdmin web panel (Vue 3)
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import { ConfigProvider } from 'ant-design-vue'
|
||||
import faIR from 'ant-design-vue/es/locale/fa_IR'
|
||||
import { saTheme } from '@/theme/tokens'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfigProvider :locale="faIR" :theme="saTheme" direction="rtl">
|
||||
<RouterView />
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE || '/api',
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('sa_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('sa_token')
|
||||
if (!location.pathname.includes('/login')) {
|
||||
location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default api
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,72 @@
|
||||
/** Default Khanegi app.theme + navigation shell for new UI configs */
|
||||
export function createDefaultKhanegiUiConfig() {
|
||||
return {
|
||||
schemaId: 'ui-config',
|
||||
version: '1.0.0',
|
||||
app: {
|
||||
schemaId: 'app',
|
||||
version: '1.0.0',
|
||||
direction: 'rtl',
|
||||
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',
|
||||
},
|
||||
borderRadius: { sm: 8, md: 12, lg: 16 },
|
||||
},
|
||||
navigation: {
|
||||
type: 'both',
|
||||
sidebar: [
|
||||
{ id: 'home', label: 'داشبورد', icon: 'vuesax-linear:chart', route: 'home', order: 1, visible: true },
|
||||
{ id: 'buy', label: 'خرید طلا', icon: 'vuesax-linear:money-receive', route: 'gold', order: 2, visible: true, query: { state: 'buy' } },
|
||||
{ id: 'sell', label: 'فروش طلا', icon: 'vuesax-linear:money-send', route: 'gold', order: 3, visible: true, query: { state: 'sell' } },
|
||||
{ id: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-1', route: 'wallet', order: 4, visible: true },
|
||||
{ id: 'transactions', label: 'تراکنشها', icon: 'vuesax-linear:receipt-item', route: 'transactions', order: 5, visible: true },
|
||||
{ id: 'products', label: 'محصولات', icon: 'vuesax-linear:coin', route: 'buyProduct', order: 7, visible: true },
|
||||
{ id: 'user', label: 'تنظیمات', icon: 'vuesax-linear:setting-2', route: 'user', order: 8, visible: true },
|
||||
],
|
||||
bottom: [
|
||||
{ id: 'gold', label: 'خرید و فروش', icon: 'vuesax-linear:coin', route: 'gold', order: 1, visible: true },
|
||||
{ id: 'transactions', label: 'تراکنشها', icon: 'vuesax-linear:receipt-item', route: 'transactions', order: 2, visible: true },
|
||||
{ id: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-add', route: 'wallet', order: 3, visible: true },
|
||||
{ id: 'user', label: 'تنظیمات', icon: 'vuesax-linear:setting-2', route: 'user', order: 4, visible: true },
|
||||
],
|
||||
sidebarReferral: {
|
||||
visible: true,
|
||||
title: 'کسب درآمد',
|
||||
subtitle: 'دعوت دوستان و دریافت پاداش',
|
||||
route: 'earnMoney',
|
||||
},
|
||||
},
|
||||
header: {
|
||||
visible: true,
|
||||
variant: 'default',
|
||||
showLogo: true,
|
||||
showNotifications: true,
|
||||
showProfile: true,
|
||||
brandSubtitle: 'پنل کاربری',
|
||||
},
|
||||
},
|
||||
pages: {},
|
||||
}
|
||||
}
|
||||
|
||||
export type UiConfig = ReturnType<typeof createDefaultKhanegiUiConfig>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { LogoutOutlined, HomeOutlined } from '@ant-design/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
function goHome() {
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<button type="button" class="brand-btn" @click="goHome">
|
||||
<span class="brand-dot" />
|
||||
<span>
|
||||
<strong>Super Admin</strong>
|
||||
<small>آبشده</small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<button type="button" class="icon-btn" title="داشبورد" @click="goHome">
|
||||
<HomeOutlined />
|
||||
</button>
|
||||
<div class="user-chip">
|
||||
<span class="avatar">{{ auth.user?.name?.slice(0, 1) || 'S' }}</span>
|
||||
<span class="user-name">{{ auth.user?.name }}</span>
|
||||
</div>
|
||||
<button type="button" class="icon-btn" title="خروج" @click="logout">
|
||||
<LogoutOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid #e8eef5;
|
||||
}
|
||||
|
||||
.brand-btn {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: #2d3748;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand-dot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(145deg, #3ad4b0, #20c997);
|
||||
box-shadow: 0 8px 18px rgba(32, 201, 151, 0.28);
|
||||
}
|
||||
|
||||
.brand-btn strong {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand-btn small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #8b95a8;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: #f3f6fb;
|
||||
color: #5b6b7c;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
border-radius: 999px;
|
||||
background: #f3f6fb;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(145deg, #9b7cff, #7c5cfc);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #2d3748;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.topbar { padding: 16px 24px; }
|
||||
.content { padding: 24px; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.user-name { display: none; }
|
||||
}
|
||||
</style>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(Antd)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import api from '@/api/http'
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<any[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/audit?limit=100')
|
||||
rows.value = data
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در دریافت لاگ')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<h1 class="page-title">لاگ تغییرات</h1>
|
||||
<p class="page-sub">آخرین عملیات روی نسخههای فروشی</p>
|
||||
|
||||
<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="action">{{ item.action }}</div>
|
||||
<div style="margin-top: 6px; color: #64748b; font-size: 0.85rem">
|
||||
نسخه: {{ item.tenant?.nameFa || item.tenantId || '—' }}
|
||||
· کاربر: {{ item.user?.name || '—' }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<a-empty v-else description="لاگی نیست" style="margin: 28px 0" />
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
MessageOutlined,
|
||||
CloudServerOutlined,
|
||||
TeamOutlined,
|
||||
HomeOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import api from '@/api/http'
|
||||
|
||||
type Tenant = { id: string; product: 'hamkar' | 'khanegi' }
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const tenants = ref<Tenant[]>([])
|
||||
|
||||
const hamkarCount = computed(() => tenants.value.filter((t) => t.product === 'hamkar').length)
|
||||
const khanegiCount = computed(() => tenants.value.filter((t) => t.product === 'khanegi').length)
|
||||
|
||||
const modules = computed(() => [
|
||||
{
|
||||
key: 'sms',
|
||||
className: 'sms',
|
||||
title: 'پنل پیامکی',
|
||||
desc: 'ارسال و مدیریت پیامک مشترک برای همکار و خانگی در یکجا',
|
||||
meta: 'مرکزی',
|
||||
icon: MessageOutlined,
|
||||
to: { name: 'sms-panel' as const },
|
||||
},
|
||||
{
|
||||
key: 'server',
|
||||
className: 'server',
|
||||
title: 'پنل سرور',
|
||||
desc: 'شارژ، تمدید و پیگیری وضعیت سرورهای نسخههای فروشی',
|
||||
meta: 'جدید',
|
||||
icon: CloudServerOutlined,
|
||||
to: { name: 'server-panel' as const },
|
||||
},
|
||||
{
|
||||
key: 'hamkar',
|
||||
className: 'hamkar',
|
||||
title: 'همکار',
|
||||
desc: 'مدیریت نسخههای فروشی پروژه همکار',
|
||||
meta: `${hamkarCount.value} نسخه`,
|
||||
icon: TeamOutlined,
|
||||
to: { name: 'product-versions' as const, params: { product: 'hamkar' } },
|
||||
},
|
||||
{
|
||||
key: 'khanegi',
|
||||
className: 'khanegi',
|
||||
title: 'خانگی',
|
||||
desc: 'مدیریت نسخههای فروشی و ظاهر اپ خانگی',
|
||||
meta: `${khanegiCount.value} نسخه`,
|
||||
icon: HomeOutlined,
|
||||
to: { name: 'product-versions' as const, params: { product: 'khanegi' } },
|
||||
},
|
||||
])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/tenants')
|
||||
tenants.value = data
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در دریافت آمار')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="login-kicker">داشبورد کنترل</div>
|
||||
<h1 class="page-title">سلام، خوش آمدید</h1>
|
||||
<p class="page-sub">
|
||||
از اینجا به پنل پیامکی، پنل سرور و نسخههای همکار / خانگی دسترسی دارید.
|
||||
</p>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="dash-modules">
|
||||
<button
|
||||
v-for="item in modules"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="module-card"
|
||||
:class="item.className"
|
||||
@click="router.push(item.to)"
|
||||
>
|
||||
<div>
|
||||
<div class="icon-wrap">
|
||||
<component :is="item.icon" />
|
||||
</div>
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p>{{ item.desc }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="progress"><span /></div>
|
||||
<div class="module-meta">
|
||||
<span>{{ item.meta }}</span>
|
||||
<span>ورود ←</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
email: 'admin@abshode.local',
|
||||
password: 'Admin@12345',
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(form.email, form.password)
|
||||
message.success('ورود موفق')
|
||||
await router.push({ name: 'home' })
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(
|
||||
Array.isArray(msg) ? msg.join('، ') : msg || e?.message || 'ورود ناموفق — آیا API روی پورت 3001 روشن است؟',
|
||||
)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-shell">
|
||||
<a-card class="login-card">
|
||||
<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: 'ایمیل نامعتبر است' },
|
||||
]"
|
||||
>
|
||||
<a-input v-model:value="form.email" size="large" autocomplete="username" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="رمز عبور"
|
||||
name="password"
|
||||
:rules="[{ required: true, min: 6, message: 'رمز حداقل ۶ کاراکتر' }]"
|
||||
>
|
||||
<a-input-password v-model:value="form.password" size="large" autocomplete="current-password" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" html-type="submit" block size="large" :loading="loading">
|
||||
ورود به داشبورد
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import api from '@/api/http'
|
||||
|
||||
type Tenant = {
|
||||
id: string
|
||||
product: 'hamkar' | 'khanegi'
|
||||
slug: string
|
||||
nameFa: string
|
||||
domain: string
|
||||
avatarUrl?: string | null
|
||||
primaryColor?: string | null
|
||||
isActive: boolean
|
||||
nodeServer: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const rows = ref<Tenant[]>([])
|
||||
const q = ref('')
|
||||
|
||||
const product = computed(() => {
|
||||
const p = String(route.params.product || '')
|
||||
return p === 'khanegi' ? 'khanegi' : 'hamkar'
|
||||
})
|
||||
|
||||
const title = computed(() => (product.value === 'hamkar' ? 'همکار' : 'خانگی'))
|
||||
const subtitle = computed(() =>
|
||||
product.value === 'hamkar'
|
||||
? 'نسخههای فروشی پروژه همکار'
|
||||
: 'نسخههای فروشی پروژه خانگی (Modular)',
|
||||
)
|
||||
|
||||
const filtered = computed(() => {
|
||||
const term = q.value.trim().toLowerCase()
|
||||
if (!term) return rows.value
|
||||
return rows.value.filter(
|
||||
(t) =>
|
||||
t.nameFa.toLowerCase().includes(term) ||
|
||||
t.domain.toLowerCase().includes(term) ||
|
||||
t.slug.toLowerCase().includes(term),
|
||||
)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/tenants', { params: { product: product.value } })
|
||||
rows.value = data
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در دریافت نسخهها')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addVersion() {
|
||||
router.push({ name: 'version-create', query: { product: product.value } })
|
||||
}
|
||||
|
||||
function edit(id: string) {
|
||||
router.push({ name: 'version-edit', params: { id } })
|
||||
}
|
||||
|
||||
function remove(record: Tenant) {
|
||||
Modal.confirm({
|
||||
title: `حذف نسخه «${record.nameFa}»؟`,
|
||||
content: 'فقط از رجیستری سوپرادمین حذف میشود؛ سرور مشتری را پاک نمیکند.',
|
||||
okText: 'حذف',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
async onOk() {
|
||||
await api.delete(`/tenants/${record.id}`)
|
||||
message.success('حذف شد')
|
||||
await load()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
watch(product, load, { immediate: true })
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 4px">
|
||||
<a-button type="text" @click="router.push({ name: 'home' })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
<p class="page-sub">{{ subtitle }} · {{ filtered.length }} مورد</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو نام، دامنه یا slug"
|
||||
style="flex: 1; min-width: 180px"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-button type="primary" @click="addVersion">
|
||||
<PlusOutlined /> نسخه جدید
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="filtered.length" class="version-list">
|
||||
<article v-for="item in filtered" :key="item.id" class="version-card">
|
||||
<div class="version-card-head">
|
||||
<img
|
||||
v-if="item.avatarUrl"
|
||||
class="version-avatar"
|
||||
:src="item.avatarUrl"
|
||||
:alt="item.nameFa"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="version-avatar fallback"
|
||||
:style="{ background: item.primaryColor || '#1d4ed8' }"
|
||||
>
|
||||
{{ item.nameFa.slice(0, 1) }}
|
||||
</div>
|
||||
<div class="version-meta">
|
||||
<h3>{{ item.nameFa }}</h3>
|
||||
<div class="domain">{{ item.domain }}</div>
|
||||
<div style="margin-top: 8px">
|
||||
<a-tag>{{ item.slug }}</a-tag>
|
||||
<a-tag :color="item.isActive ? 'success' : 'default'">
|
||||
{{ item.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
<a-button type="primary" ghost block style="flex: 1" @click="edit(item.id)">
|
||||
مدیریت
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="product === 'khanegi'"
|
||||
type="default"
|
||||
block
|
||||
style="flex: 1"
|
||||
@click="router.push({ name: 'version-ui', params: { id: item.id } })"
|
||||
>
|
||||
ظاهر اپ
|
||||
</a-button>
|
||||
<a-button danger ghost style="flex: 0 0 auto" @click="remove(item)">حذف</a-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<a-empty v-else description="نسخهای ثبت نشده" style="margin: 32px 0">
|
||||
<a-button type="primary" @click="addVersion">افزودن اولین نسخه</a-button>
|
||||
</a-empty>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowRightOutlined, CloudServerOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
</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>
|
||||
<h1 class="page-title">پنل سرور</h1>
|
||||
<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>
|
||||
|
||||
<div class="hint-box">
|
||||
اسکلت صفحه آماده است. جزئیات业务流程 و اتصال به سرویسها را در مرحله بعد اضافه میکنیم.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowRightOutlined, MessageOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
</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>
|
||||
<h1 class="page-title">پنل پیامکی</h1>
|
||||
<p class="page-sub">مرکز مشترک پیامک برای همکار و خانگی</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-hero sms">
|
||||
<div style="display: flex; align-items: center; gap: 12px; font-size: 28px">
|
||||
<MessageOutlined />
|
||||
<strong style="font-size: 1.2rem">بهزودی</strong>
|
||||
</div>
|
||||
<p style="margin: 12px 0 0; opacity: 0.92; line-height: 1.8">
|
||||
اینجا پنل پیامکی مرکزی میآید تا دیگر لازم نباشد در هر پروژه جداگانه مدیریت شود.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hint-box">
|
||||
فعلاً اسکلت صفحه آماده است. در مرحله بعد اتصال به سرویس پیامک و تنظیمات هر نسخه را اضافه میکنیم.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,217 @@
|
||||
<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'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const isEdit = computed(() => Boolean(route.params.id))
|
||||
|
||||
const form = reactive({
|
||||
product: 'hamkar' as 'hamkar' | 'khanegi',
|
||||
slug: '',
|
||||
nameFa: '',
|
||||
nameEn: '',
|
||||
domain: '',
|
||||
avatarUrl: '',
|
||||
primaryColor: '#1d4ed8',
|
||||
secondaryColor: '#0f172a',
|
||||
backgroundColor: '#ffffff',
|
||||
nodeServer: '',
|
||||
nodeWs: '',
|
||||
appUrl: '',
|
||||
adminUrl: '',
|
||||
remoteApiToken: '',
|
||||
isActive: true,
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const productLabel = computed(() => (form.product === 'hamkar' ? 'همکار' : 'خانگی'))
|
||||
|
||||
function backTarget() {
|
||||
return {
|
||||
name: 'product-versions' as const,
|
||||
params: { product: form.product },
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/tenants/${route.params.id}`)
|
||||
Object.assign(form, {
|
||||
product: data.product,
|
||||
slug: data.slug,
|
||||
nameFa: data.nameFa,
|
||||
nameEn: data.nameEn || '',
|
||||
domain: data.domain,
|
||||
avatarUrl: data.avatarUrl || '',
|
||||
primaryColor: data.primaryColor || '#1d4ed8',
|
||||
secondaryColor: data.secondaryColor || '#0f172a',
|
||||
backgroundColor: data.backgroundColor || '#ffffff',
|
||||
nodeServer: data.nodeServer,
|
||||
nodeWs: data.nodeWs || '',
|
||||
appUrl: data.appUrl || '',
|
||||
adminUrl: data.adminUrl || '',
|
||||
remoteApiToken: '',
|
||||
isActive: data.isActive,
|
||||
notes: data.notes || '',
|
||||
})
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در بارگذاری')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { ...form }
|
||||
if (!payload.remoteApiToken) {
|
||||
delete (payload as any).remoteApiToken
|
||||
}
|
||||
// node_ws فقط برای همکار حیاتی است؛ برای خانگی اختیاری (SSE)
|
||||
if (isEdit.value) {
|
||||
await api.patch(`/tenants/${route.params.id}`, payload)
|
||||
message.success('بهروزرسانی شد')
|
||||
} else {
|
||||
await api.post('/tenants', payload)
|
||||
message.success('نسخه ایجاد شد')
|
||||
}
|
||||
router.push(backTarget())
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message
|
||||
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, load)
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell" v-if="!loading">
|
||||
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 12px">
|
||||
<a-button type="text" @click="router.push(backTarget())">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<h1 class="page-title">{{ isEdit ? 'مدیریت نسخه' : 'نسخه فروشی جدید' }}</h1>
|
||||
<p class="page-sub">محصول: {{ productLabel }} · تنظیمات رجیستری برند (/get_user)</p>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="isEdit && form.product === 'khanegi'"
|
||||
type="primary"
|
||||
ghost
|
||||
@click="router.push({ name: 'version-ui', params: { id: route.params.id } })"
|
||||
>
|
||||
ظاهر اپ
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-form :model="form" layout="vertical" @finish="save">
|
||||
<section class="form-section">
|
||||
<h3>هویت نسخه</h3>
|
||||
<a-form-item label="محصول" name="product">
|
||||
<a-select v-model:value="form.product" :disabled="isEdit">
|
||||
<a-select-option value="hamkar">همکار</a-select-option>
|
||||
<a-select-option value="khanegi">خانگی</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="نام فارسی" name="nameFa" :rules="[{ required: true, message: 'لازم است' }]">
|
||||
<a-input v-model:value="form.nameFa" size="large" placeholder="مثلاً باختر گلد" />
|
||||
</a-form-item>
|
||||
<a-form-item label="slug لاتین" name="slug" :rules="[{ required: true, message: 'لازم است' }]">
|
||||
<a-input v-model:value="form.slug" size="large" placeholder="bakhtargold" dir="ltr" />
|
||||
</a-form-item>
|
||||
<a-form-item label="نام لاتین (اختیاری)" name="nameEn">
|
||||
<a-input v-model:value="form.nameEn" size="large" dir="ltr" />
|
||||
</a-form-item>
|
||||
<a-form-item label="دامنه فروشگاه" name="domain" :rules="[{ required: true, message: 'لازم است' }]">
|
||||
<a-input v-model:value="form.domain" size="large" placeholder="example.com" dir="ltr" />
|
||||
</a-form-item>
|
||||
</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://..." />
|
||||
</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">
|
||||
<h3>اتصال سرور این نسخه</h3>
|
||||
<a-form-item label="آدرس API (node_server)" name="nodeServer" :rules="[{ required: true, message: 'لازم است' }]">
|
||||
<a-input v-model:value="form.nodeServer" size="large" dir="ltr" placeholder="https://example.com/server" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
:label="form.product === 'hamkar' ? 'WebSocket (node_ws)' : 'WebSocket اختیاری (خانگی بیشتر SSE است)'"
|
||||
name="nodeWs"
|
||||
>
|
||||
<a-input v-model:value="form.nodeWs" size="large" dir="ltr" />
|
||||
</a-form-item>
|
||||
<a-form-item label="app_url" name="appUrl">
|
||||
<a-input v-model:value="form.appUrl" size="large" dir="ltr" />
|
||||
</a-form-item>
|
||||
<a-form-item label="admin_url" name="adminUrl">
|
||||
<a-input v-model:value="form.adminUrl" size="large" dir="ltr" />
|
||||
</a-form-item>
|
||||
<a-form-item label="توکن ریموت سوپر (برای تنظیمات داخل سرور نسخه)" name="remoteApiToken">
|
||||
<a-input-password v-model:value="form.remoteApiToken" size="large" />
|
||||
</a-form-item>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>وضعیت</h3>
|
||||
<a-form-item label="فعال در رجیستری">
|
||||
<a-switch v-model:checked="form.isActive" />
|
||||
</a-form-item>
|
||||
<a-form-item label="یادداشت داخلی">
|
||||
<a-textarea v-model:value="form.notes" :rows="3" />
|
||||
</a-form-item>
|
||||
</section>
|
||||
|
||||
<div class="sticky-actions">
|
||||
<a-button type="primary" html-type="submit" size="large" block :loading="saving">ذخیره</a-button>
|
||||
<a-button size="large" @click="router.push(backTarget())">انصراف</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</div>
|
||||
<div v-else class="page-shell" style="display: grid; place-items: center; min-height: 200px">
|
||||
<a-spin />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,412 @@
|
||||
<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 { createDefaultKhanegiUiConfig } from '@/config/defaultKhanegiUiConfig'
|
||||
import { colorFieldLabels, colorPalettes } from '@/theme/tokens'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const tenantName = ref('')
|
||||
const product = ref<'hamkar' | 'khanegi'>('khanegi')
|
||||
const activeTab = ref('theme')
|
||||
const advancedJson = ref('')
|
||||
const jsonError = ref('')
|
||||
|
||||
const config = reactive(createDefaultKhanegiUiConfig())
|
||||
|
||||
const colorKeys = computed(() => Object.keys(config.app.theme.colors))
|
||||
|
||||
const previewStyle = computed(() => ({
|
||||
background: config.app.theme.colors.pageBg,
|
||||
color: config.app.theme.colors.textPrimary,
|
||||
borderRadius: `${config.app.theme.borderRadius.lg}px`,
|
||||
}))
|
||||
|
||||
const accentStyle = computed(() => ({
|
||||
background: config.app.theme.colors.secondary,
|
||||
color: config.app.theme.colors.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 syncAdvancedJson() {
|
||||
advancedJson.value = JSON.stringify(config, null, 2)
|
||||
jsonError.value = ''
|
||||
}
|
||||
|
||||
function applyAdvancedJson() {
|
||||
try {
|
||||
const parsed = JSON.parse(advancedJson.value)
|
||||
Object.assign(config, parsed)
|
||||
jsonError.value = ''
|
||||
message.success('JSON اعمال شد')
|
||||
} catch (e: any) {
|
||||
jsonError.value = e?.message || 'JSON نامعتبر'
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get(`/tenants/${route.params.id}`)
|
||||
tenantName.value = data.nameFa
|
||||
product.value = data.product
|
||||
if (data.product !== 'khanegi') {
|
||||
message.warning('ظاهر اپ فعلاً فقط برای خانگی فعال است')
|
||||
router.replace({ name: 'version-edit', params: { id: String(route.params.id) } })
|
||||
return
|
||||
}
|
||||
if (data.appUiConfig && typeof data.appUiConfig === 'object') {
|
||||
Object.assign(config, createDefaultKhanegiUiConfig(), data.appUiConfig)
|
||||
if (!config.app?.theme?.colors) {
|
||||
Object.assign(config, createDefaultKhanegiUiConfig())
|
||||
}
|
||||
}
|
||||
syncAdvancedJson()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'خطا در بارگذاری')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const colors = config.app.theme.colors
|
||||
await api.patch(`/tenants/${route.params.id}`, {
|
||||
appUiConfig: JSON.parse(JSON.stringify(config)),
|
||||
primaryColor: colors.primary,
|
||||
secondaryColor: colors.secondary,
|
||||
backgroundColor: colors.background,
|
||||
})
|
||||
message.success('ظاهر اپ ذخیره شد')
|
||||
syncAdvancedJson()
|
||||
} 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 v-if="!loading" class="page-shell ui-editor">
|
||||
<div class="ui-editor-top">
|
||||
<a-button type="text" class="back-btn" @click="router.push({ name: 'version-edit', params: { id: route.params.id } })">
|
||||
<ArrowRightOutlined />
|
||||
</a-button>
|
||||
<div style="flex: 1">
|
||||
<h1 class="page-title">ظاهر اپ خانگی</h1>
|
||||
<p class="page-sub">{{ tenantName }} · app_ui_config</p>
|
||||
</div>
|
||||
<a-button type="primary" size="large" :loading="saving" @click="save">ذخیره ظاهر</a-button>
|
||||
</div>
|
||||
|
||||
<div class="preview-phone" :style="previewStyle">
|
||||
<div class="preview-bar">
|
||||
<span>{{ tenantName || 'پیشنمایش' }}</span>
|
||||
<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 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 }">
|
||||
متن فرعی با رنگ theme
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<section class="form-section">
|
||||
<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>
|
||||
</a-radio-group>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>رنگها</h3>
|
||||
<div class="color-grid">
|
||||
<label v-for="key in colorKeys" :key="key" class="color-field">
|
||||
<span>{{ colorFieldLabels[key] || key }}</span>
|
||||
<div class="color-row">
|
||||
<span
|
||||
class="color-swatch"
|
||||
:style="{ background: (config.app.theme.colors as any)[key] }"
|
||||
/>
|
||||
<input
|
||||
v-if="String((config.app.theme.colors as any)[key] || '').startsWith('#')"
|
||||
v-model="(config.app.theme.colors as any)[key]"
|
||||
type="color"
|
||||
class="color-native"
|
||||
/>
|
||||
<a-input v-model:value="(config.app.theme.colors as any)[key]" dir="ltr" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>گوشهها (border radius)</h3>
|
||||
<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-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-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-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</section>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="nav" tab="منوها">
|
||||
<section class="form-section">
|
||||
<h3>منوی کناری</h3>
|
||||
<div v-for="item in config.app.navigation.sidebar" :key="item.id" class="nav-row">
|
||||
<a-switch v-model:checked="item.visible" />
|
||||
<a-input v-model:value="item.label" />
|
||||
<span class="nav-route" dir="ltr">{{ item.route }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="form-section">
|
||||
<h3>منوی پایین موبایل</h3>
|
||||
<div v-for="item in config.app.navigation.bottom" :key="item.id" class="nav-row">
|
||||
<a-switch v-model:checked="item.visible" />
|
||||
<a-input v-model:value="item.label" />
|
||||
<span class="nav-route" dir="ltr">{{ item.route }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="form-section">
|
||||
<h3>کارت دعوت (سایدبار)</h3>
|
||||
<a-form-item label="نمایش">
|
||||
<a-switch v-model:checked="config.app.navigation.sidebarReferral.visible" />
|
||||
</a-form-item>
|
||||
<a-form-item label="عنوان">
|
||||
<a-input v-model:value="config.app.navigation.sidebarReferral.title" />
|
||||
</a-form-item>
|
||||
<a-form-item label="زیرعنوان">
|
||||
<a-input v-model:value="config.app.navigation.sidebarReferral.subtitle" />
|
||||
</a-form-item>
|
||||
</section>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="header" tab="هدر">
|
||||
<a-form-item label="نمایش هدر">
|
||||
<a-switch v-model:checked="config.app.header.visible" />
|
||||
</a-form-item>
|
||||
<a-form-item label="لوگو">
|
||||
<a-switch v-model:checked="config.app.header.showLogo" />
|
||||
</a-form-item>
|
||||
<a-form-item label="اعلانها">
|
||||
<a-switch v-model:checked="config.app.header.showNotifications" />
|
||||
</a-form-item>
|
||||
<a-form-item label="پروفایل">
|
||||
<a-switch v-model:checked="config.app.header.showProfile" />
|
||||
</a-form-item>
|
||||
<a-form-item label="زیرعنوان برند">
|
||||
<a-input v-model:value="config.app.header.brandSubtitle" />
|
||||
</a-form-item>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="json" tab="JSON پیشرفته">
|
||||
<p class="page-sub" style="margin-bottom: 12px">
|
||||
برای pages و جزئیات کامل میتوانید JSON را مستقیم ویرایش کنید. اگر از قبل در دیتابیس مشتری pages دارید، اینجا پیست کنید.
|
||||
</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>
|
||||
<div style="display: flex; gap: 8px; margin-top: 12px">
|
||||
<a-button @click="syncAdvancedJson">بازنشانی از فرم</a-button>
|
||||
<a-button type="primary" ghost @click="applyAdvancedJson">اعمال JSON روی فرم</a-button>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<div class="sticky-actions">
|
||||
<a-button type="primary" size="large" block :loading="saving" @click="save">ذخیره ظاهر</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="page-shell" style="display: grid; place-items: center; min-height: 240px">
|
||||
<a-spin size="large" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-editor-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-phone {
|
||||
padding: 16px;
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.preview-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-chip {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
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;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.color-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.color-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.color-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-native {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.nav-route {
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.json-box {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/pages/LoginPage.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/AppLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('@/pages/HomePage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'sms',
|
||||
name: 'sms-panel',
|
||||
component: () => import('@/pages/SmsPanelPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'servers',
|
||||
name: 'server-panel',
|
||||
component: () => import('@/pages/ServerPanelPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'products/:product',
|
||||
name: 'product-versions',
|
||||
component: () => import('@/pages/ProductVersionsPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'versions/new',
|
||||
name: 'version-create',
|
||||
component: () => import('@/pages/TenantFormPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'versions/:id',
|
||||
name: 'version-edit',
|
||||
component: () => import('@/pages/TenantFormPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'versions/:id/ui',
|
||||
name: 'version-ui',
|
||||
component: () => import('@/pages/UiConfigPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'audit',
|
||||
component: () => import('@/pages/AuditPage.vue'),
|
||||
},
|
||||
// legacy redirects
|
||||
{ path: 'tenants', redirect: { name: 'home' } },
|
||||
{ path: 'tenants/new', redirect: { name: 'home' } },
|
||||
{
|
||||
path: 'tenants/:id',
|
||||
redirect: (to) => ({ name: 'version-edit', params: { id: to.params.id } }),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (to.meta.public) return true
|
||||
if (!auth.isAuthenticated) return { name: 'login' }
|
||||
if (!auth.user) {
|
||||
try {
|
||||
await auth.fetchMe()
|
||||
} catch {
|
||||
auth.logout()
|
||||
return { name: 'login' }
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import api from '@/api/http'
|
||||
|
||||
type User = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('sa_token') || '')
|
||||
const user = ref<User | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(token.value))
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const { data } = await api.post('/auth/login', { email, password })
|
||||
token.value = data.accessToken
|
||||
user.value = data.user
|
||||
localStorage.setItem('sa_token', data.accessToken)
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
if (!token.value) return null
|
||||
const { data } = await api.get('/auth/me')
|
||||
user.value = data
|
||||
return data
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
localStorage.removeItem('sa_token')
|
||||
}
|
||||
|
||||
return { token, user, isAuthenticated, login, fetchMe, logout }
|
||||
})
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
@font-face {
|
||||
font-family: 'Dana';
|
||||
src: url('./assets/fonts/Dana/Dana-Thin.ttf') format('truetype');
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Dana';
|
||||
src: url('./assets/fonts/Dana/Dana-ExtraLight.ttf') format('truetype');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Dana';
|
||||
src: url('./assets/fonts/Dana/Dana-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Dana';
|
||||
src: url('./assets/fonts/Dana/Dana-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Dana';
|
||||
src: url('./assets/fonts/Dana/Dana-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Dana';
|
||||
src: url('./assets/fonts/Dana/Dana-DemiBold.ttf') format('truetype');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Dana';
|
||||
src: url('./assets/fonts/Dana/Dana-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Dana';
|
||||
src: url('./assets/fonts/Dana/Dana-ExtraBold.ttf') format('truetype');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Dana';
|
||||
src: url('./assets/fonts/Dana/Dana-Black.ttf') format('truetype');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Dana';
|
||||
src: url('./assets/fonts/Dana/Dana-UltraBold.ttf') format('truetype');
|
||||
font-weight: 950;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #eef2f7;
|
||||
--panel: #ffffff;
|
||||
--ink: #2d3748;
|
||||
--ink-soft: #8b95a8;
|
||||
--line: #e8eef5;
|
||||
--teal: #20c997;
|
||||
--teal-deep: #168f81;
|
||||
--purple: #7c5cfc;
|
||||
--coral: #ff6b6b;
|
||||
--orange: #ffb84d;
|
||||
--shadow: 0 12px 32px rgba(45, 55, 72, 0.08);
|
||||
--shadow-hover: 0 18px 40px rgba(45, 55, 72, 0.12);
|
||||
--radius: 24px;
|
||||
--font-dana: 'Dana', Tahoma, sans-serif;
|
||||
font-family: var(--font-dana);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
font-family: var(--font-dana);
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button, input, textarea, select,
|
||||
.ant-btn, .ant-input, .ant-select, .ant-menu, .ant-modal,
|
||||
.ant-message, .ant-form, .ant-typography, .ant-tabs {
|
||||
font-family: var(--font-dana) !important;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
font-weight: 800;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page-shell { padding: 28px; }
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
color: var(--ink);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
margin-top: 6px;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dash-modules {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.dash-modules {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1100px) {
|
||||
.dash-modules {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.module-card {
|
||||
appearance: none;
|
||||
border: none;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
min-height: 168px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.module-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
.module-card .icon-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.module-card.sms .icon-wrap { background: linear-gradient(145deg, #9b7cff, #7c5cfc); }
|
||||
.module-card.server .icon-wrap { background: linear-gradient(145deg, #ff8a8a, #ff6b6b); }
|
||||
.module-card.hamkar .icon-wrap { background: linear-gradient(145deg, #3ad4b0, #20c997); }
|
||||
.module-card.khanegi .icon-wrap { background: linear-gradient(145deg, #ffc86b, #ffb84d); }
|
||||
|
||||
.module-card h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.module-card p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.module-card .module-meta {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.module-card .progress {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #edf2f7;
|
||||
overflow: hidden;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.module-card .progress > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.module-card.sms .progress > span { background: #7c5cfc; width: 72%; }
|
||||
.module-card.server .progress > span { background: #ff6b6b; width: 54%; }
|
||||
.module-card.hamkar .progress > span { background: #20c997; width: 80%; }
|
||||
.module-card.khanegi .progress > span { background: #ffb84d; width: 68%; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 14px 0 16px;
|
||||
}
|
||||
|
||||
.version-list { display: grid; gap: 12px; }
|
||||
|
||||
.version-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.version-card:hover {
|
||||
border-color: rgba(32, 201, 151, 0.35);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.version-card-head {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.version-avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
background: #f1f5f9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.version-avatar.fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.version-meta { flex: 1; min-width: 0; }
|
||||
|
||||
.version-meta h3 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
color: var(--ink);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.version-meta .domain {
|
||||
margin-top: 4px;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.85rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.audit-list { display: grid; gap: 10px; }
|
||||
|
||||
.audit-item {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.audit-item .when { color: var(--ink-soft); font-size: 0.8rem; }
|
||||
.audit-item .action { margin-top: 4px; font-weight: 700; color: var(--ink); }
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.form-section:last-of-type { border-bottom: none; }
|
||||
|
||||
.form-section h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.98rem;
|
||||
color: var(--ink);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.sticky-actions {
|
||||
position: sticky;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
background: linear-gradient(to top, #fff 72%, rgba(255, 255, 255, 0));
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background:
|
||||
radial-gradient(600px 320px at 90% 0%, rgba(32, 201, 151, 0.18), transparent 60%),
|
||||
radial-gradient(520px 280px at 0% 100%, rgba(124, 92, 252, 0.14), transparent 55%),
|
||||
#eef2f7;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(440px, 100%);
|
||||
border-radius: 28px !important;
|
||||
border: none !important;
|
||||
background: #fff !important;
|
||||
box-shadow: var(--shadow) !important;
|
||||
}
|
||||
|
||||
.login-card :deep(.ant-card-body) {
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.login-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(32, 201, 151, 0.12);
|
||||
color: var(--teal-deep);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.hint-box {
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: #f5f8fc;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.placeholder-hero {
|
||||
border-radius: 22px;
|
||||
padding: 22px;
|
||||
color: #fff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.placeholder-hero.sms {
|
||||
background: linear-gradient(135deg, #9b7cff, #7c5cfc);
|
||||
}
|
||||
|
||||
.placeholder-hero.server {
|
||||
background: linear-gradient(135deg, #ff8a8a, #ff6b6b);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context'
|
||||
|
||||
/** Soft light dashboard theme inspired by the reference UI */
|
||||
export const saTheme: ThemeConfig = {
|
||||
token: {
|
||||
fontFamily: "'Dana', Tahoma, sans-serif",
|
||||
colorPrimary: '#20c997',
|
||||
colorInfo: '#7c5cfc',
|
||||
colorSuccess: '#20c997',
|
||||
colorWarning: '#ffb84d',
|
||||
colorError: '#ff6b6b',
|
||||
borderRadius: 16,
|
||||
controlHeight: 42,
|
||||
controlHeightLG: 48,
|
||||
colorBgContainer: '#ffffff',
|
||||
colorBgLayout: '#eef2f7',
|
||||
colorText: '#2d3748',
|
||||
colorTextSecondary: '#8b95a8',
|
||||
colorBorder: '#e8eef5',
|
||||
colorLink: '#20c997',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
primaryShadow: '0 10px 24px rgba(32, 201, 151, 0.28)',
|
||||
fontWeight: 600,
|
||||
borderRadius: 14,
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 24,
|
||||
},
|
||||
Input: {
|
||||
borderRadius: 14,
|
||||
controlHeight: 44,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const colorPalettes = [
|
||||
{
|
||||
id: 'navy-gold',
|
||||
name: 'سرمهای طلایی',
|
||||
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',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'deep-emerald',
|
||||
name: 'زمردی تیره',
|
||||
colors: {
|
||||
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',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'royal-blue',
|
||||
name: 'آبی سلطنتی',
|
||||
colors: {
|
||||
primary: '#1e3a8a',
|
||||
secondary: '#f59e0b',
|
||||
tertiary: '#d97706',
|
||||
background: '#ffffff',
|
||||
surface: '#1a2238',
|
||||
surfaceHover: '#243049',
|
||||
textPrimary: '#ffffff',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.48)',
|
||||
textSubtle: 'rgba(255, 255, 255, 0.34)',
|
||||
border: 'rgba(255, 255, 255, 0.15)',
|
||||
borderSubtle: 'rgba(255, 255, 255, 0.1)',
|
||||
pageBg: '#0f1524',
|
||||
pageBgMobile: '#0b101c',
|
||||
onAccent: '#0f172a',
|
||||
success: '#16a34a',
|
||||
warning: '#f59e0b',
|
||||
danger: '#dc2626',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'warm-bronze',
|
||||
name: 'برنز گرم',
|
||||
colors: {
|
||||
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',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'light-clean',
|
||||
name: 'روشن تمیز',
|
||||
colors: {
|
||||
primary: '#0f172a',
|
||||
secondary: '#20c997',
|
||||
tertiary: '#168f81',
|
||||
background: '#ffffff',
|
||||
surface: '#f8fafc',
|
||||
surfaceHover: '#f1f5f9',
|
||||
textPrimary: '#0f172a',
|
||||
textSecondary: 'rgba(15, 23, 42, 0.55)',
|
||||
textSubtle: 'rgba(15, 23, 42, 0.4)',
|
||||
border: 'rgba(15, 23, 42, 0.12)',
|
||||
borderSubtle: 'rgba(15, 23, 42, 0.08)',
|
||||
pageBg: '#eef2f7',
|
||||
pageBgMobile: '#e8edf5',
|
||||
onAccent: '#ffffff',
|
||||
success: '#16a34a',
|
||||
warning: '#f59e0b',
|
||||
danger: '#dc2626',
|
||||
},
|
||||
},
|
||||
] as const
|
||||
|
||||
export const colorFieldLabels: Record<string, string> = {
|
||||
primary: 'اصلی',
|
||||
secondary: 'ثانویه / اکسنت',
|
||||
tertiary: 'سوم',
|
||||
background: 'پسزمینه روشن',
|
||||
surface: 'سطح',
|
||||
surfaceHover: 'سطح هاور',
|
||||
textPrimary: 'متن اصلی',
|
||||
textSecondary: 'متن فرعی',
|
||||
textSubtle: 'متن کمرنگ',
|
||||
border: 'حاشیه',
|
||||
borderSubtle: 'حاشیه ظریف',
|
||||
pageBg: 'پسزمینه صفحه',
|
||||
pageBgMobile: 'پسزمینه موبایل',
|
||||
onAccent: 'متن روی اکسنت',
|
||||
success: 'موفق',
|
||||
warning: 'هشدار',
|
||||
danger: 'خطا',
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user