Initial SuperAdmin web panel (Vue 3)
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user