Initial SuperAdmin web panel (Vue 3)

This commit is contained in:
SuperAdmin
2026-07-23 10:24:50 +03:30
commit 0f94a49b8d
63 changed files with 4762 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# dependencies
node_modules
# build
dist
coverage
# env & local
.env
.env.local
.env.*.local
# logs & ide
*.log
.DS_Store
.idea
.vscode
*.tsbuildinfo
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Super Admin آبشده</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2517
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6",
"axios": "^1.18.1",
"pinia": "^3.0.4",
"vue": "^3.5.18",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.8.3",
"vite": "^7.0.6",
"vue-tsc": "^3.0.5"
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#0b1f33"/>
<circle cx="32" cy="32" r="14" fill="#c9a227"/>
<path d="M22 34c4 6 16 6 20 0" stroke="#0b1f33" stroke-width="3" fill="none" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 278 B

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+12
View File
@@ -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>
+28
View File
@@ -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.
+72
View File
@@ -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>
+168
View File
@@ -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
View File
@@ -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')
+43
View File
@@ -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>
+112
View File
@@ -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>
+65
View File
@@ -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>
+164
View File
@@ -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>
+34
View File
@@ -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>
+34
View File
@@ -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>
+217
View File
@@ -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>
+412
View File
@@ -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>
+84
View File
@@ -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
+39
View File
@@ -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
View File
@@ -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);
}
+174
View File
@@ -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: 'خطا',
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/get_user': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
})