feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/views/manage/Const.vue (977 lines) — the
|
||||
"مدیریت ثابت ها" screen, which manages several unrelated things in one card:
|
||||
brands, tags, the mobile home slider, collections ("سینی") and site config.
|
||||
|
||||
Reorganised into tabs rather than one long scroll, because the legacy page is
|
||||
a single card with five stacked sections and no way to navigate between them.
|
||||
Every section keeps its own Farsi labels and its own rid gate.
|
||||
|
||||
The slider section is IMAGE-based in legacy (an uploader per slide), and is
|
||||
one here too — each slide uses the shared ImageUploadInput, which posts to the
|
||||
ported `file/create` endpoint and stores the returned URL.
|
||||
-->
|
||||
<a-card v-if="hasPermission('config.statics')" title="مدیریت ثابت ها">
|
||||
<a-spin :spinning="loading">
|
||||
<a-tabs>
|
||||
<!-- برندها -->
|
||||
<a-tab-pane key="brands" tab="برند ها">
|
||||
<div class="row">
|
||||
<a-input
|
||||
v-model:value="newBrand"
|
||||
placeholder="نام برند"
|
||||
style="max-width: 260px"
|
||||
@pressEnter="addBrand"
|
||||
/>
|
||||
<a-button
|
||||
v-if="hasPermission('config.statics.brand.create')"
|
||||
type="primary"
|
||||
@click="addBrand"
|
||||
>
|
||||
افزودن
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
class="mt-4"
|
||||
size="small"
|
||||
:data-source="brands"
|
||||
:columns="brandColumns"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'op'">
|
||||
<a-tooltip v-if="hasPermission('config.statics.brand.edit')" title="ویرایش">
|
||||
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="editBrand(record)" />
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="hasPermission('config.statics.brand.remove')" title="حذف">
|
||||
<a-popconfirm
|
||||
ok-text="بله"
|
||||
cancel-text="خیر"
|
||||
title="آیا مطمئن هستید؟"
|
||||
@confirm="removeBrand(record.id)"
|
||||
>
|
||||
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
|
||||
</a-popconfirm>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- برچسبها -->
|
||||
<a-tab-pane key="tags" tab="برچسب ها">
|
||||
<div class="row">
|
||||
<a-input
|
||||
v-model:value="tagsInputValue"
|
||||
placeholder="برچسب"
|
||||
style="max-width: 260px"
|
||||
@pressEnter="addTag"
|
||||
/>
|
||||
<a-button
|
||||
v-if="hasPermission('config.statics.tags.create')"
|
||||
type="primary"
|
||||
@click="addTag"
|
||||
>
|
||||
افزودن
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 tags">
|
||||
<a-tag
|
||||
v-for="(t, i) in tags"
|
||||
:key="i"
|
||||
:closable="hasPermission('config.statics.tags.remove')"
|
||||
@close="removeTag(i)"
|
||||
>
|
||||
{{ t }}
|
||||
</a-tag>
|
||||
<a-empty v-if="tags.length === 0" description="برچسبی ثبت نشده است" />
|
||||
</div>
|
||||
|
||||
<a-button class="mt-4" type="primary" @click="saveTags">ذخیره برچسب ها</a-button>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- اسلایدر موبایل -->
|
||||
<a-tab-pane key="slider" tab="اسلایدر موبایل">
|
||||
<div v-for="(s, index) in sliders" :key="index" class="slide">
|
||||
<h6 class="mobile-c">اسلاید {{ index + 1 }}</h6>
|
||||
<div class="row">
|
||||
<ImageUploadInput v-model="s.image" purpose="slider" placeholder="تصویر اسلاید" />
|
||||
<a-input v-model:value="s.link" placeholder="لینک" />
|
||||
<a-button danger @click="sliders.splice(index, 1)">حذف</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<a-button @click="sliders.push({ image: '', link: '' })">افزودن اسلاید</a-button>
|
||||
<a-button type="primary" @click="saveSliders">ذخیره اسلایدر</a-button>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- سینیها -->
|
||||
<a-tab-pane key="collections" tab="سینی ها">
|
||||
<div class="row">
|
||||
<a-input
|
||||
v-model:value="newCollection"
|
||||
placeholder="نام سینی"
|
||||
style="max-width: 260px"
|
||||
@pressEnter="addCollection"
|
||||
/>
|
||||
<a-button type="primary" @click="addCollection">
|
||||
{{ collectionIsEdit ? 'ویرایش' : 'ثبت' }} سینی
|
||||
</a-button>
|
||||
<a-button v-if="collectionIsEdit" @click="cancelCollectionEdit">انصراف</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
class="mt-4"
|
||||
size="small"
|
||||
:data-source="collections"
|
||||
:columns="collectionColumns"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'op'">
|
||||
<a-tooltip title="ویرایش">
|
||||
<EditOutlined
|
||||
class="cursor-pointer mx-1 text-primary"
|
||||
@click="editCollection(record)"
|
||||
/>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="حذف">
|
||||
<a-popconfirm
|
||||
ok-text="بله"
|
||||
cancel-text="خیر"
|
||||
title="آیا مطمئن هستید؟"
|
||||
@confirm="removeCollection(record.id)"
|
||||
>
|
||||
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
|
||||
</a-popconfirm>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-spin>
|
||||
|
||||
<a-modal v-model:open="brandModal" title="ویرایش برند" @ok="saveBrandEdit">
|
||||
<a-input v-model:value="brandEditTitle" />
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
import api from '../../api/client';
|
||||
import ImageUploadInput from '../../components/upload/ImageUploadInput.vue';
|
||||
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
// ── brands
|
||||
const brands = ref<{ id: string; title: string }[]>([]);
|
||||
const newBrand = ref('');
|
||||
const brandModal = ref(false);
|
||||
const brandEditTitle = ref('');
|
||||
const brandEditId = ref<string | undefined>(undefined);
|
||||
const brandColumns = [
|
||||
{ title: 'عنوان', dataIndex: 'title' },
|
||||
{ title: 'عملیات', dataIndex: 'op' },
|
||||
];
|
||||
|
||||
// ── tags (stored as one setting key, as legacy did with __list__tags)
|
||||
const tags = ref<string[]>([]);
|
||||
const tagsInputValue = ref('');
|
||||
|
||||
// ── mobile slider (legacy key: mobile_main_slider)
|
||||
const sliders = ref<{ image: string; link: string }[]>([]);
|
||||
|
||||
// ── collections
|
||||
const collections = ref<{ id: string; title: string }[]>([]);
|
||||
const newCollection = ref('');
|
||||
const collectionIsEdit = ref(false);
|
||||
const collectionEditId = ref<string | undefined>(undefined);
|
||||
const collectionColumns = [
|
||||
{ title: 'عنوان', dataIndex: 'title' },
|
||||
{ title: 'عملیات', dataIndex: 'op' },
|
||||
];
|
||||
|
||||
// ── brands
|
||||
async function loadBrands() {
|
||||
const { data } = await api.get('brand');
|
||||
brands.value = data;
|
||||
}
|
||||
|
||||
async function addBrand() {
|
||||
if (!newBrand.value.trim()) return;
|
||||
try {
|
||||
await api.post('brand/create', { title: newBrand.value.trim() });
|
||||
newBrand.value = '';
|
||||
await loadBrands();
|
||||
message.success('برند اضافه شد');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در افزودن برند');
|
||||
}
|
||||
}
|
||||
|
||||
function editBrand(record: { id: string; title: string }) {
|
||||
brandEditId.value = record.id;
|
||||
brandEditTitle.value = record.title;
|
||||
brandModal.value = true;
|
||||
}
|
||||
|
||||
async function saveBrandEdit() {
|
||||
try {
|
||||
await api.put(`brand/${brandEditId.value}`, { title: brandEditTitle.value });
|
||||
brandModal.value = false;
|
||||
await loadBrands();
|
||||
message.success('تغییرات ثبت شد');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در ویرایش');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeBrand(id: string) {
|
||||
try {
|
||||
await api.delete(`brand/${id}`);
|
||||
await loadBrands();
|
||||
message.success('برند حذف شد');
|
||||
} catch (e: any) {
|
||||
// refused while products reference it
|
||||
Modal.error({ title: e?.response?.data?.message ?? 'خطا در حذف برند', okText: 'بستن' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── tags
|
||||
function addTag() {
|
||||
const v = tagsInputValue.value.trim();
|
||||
if (!v) return;
|
||||
if (!tags.value.includes(v)) tags.value.push(v);
|
||||
tagsInputValue.value = '';
|
||||
}
|
||||
|
||||
function removeTag(index: number) {
|
||||
tags.value.splice(index, 1);
|
||||
}
|
||||
|
||||
async function saveTags() {
|
||||
try {
|
||||
await api.put('const/__list__tags', { val: tags.value });
|
||||
message.success('برچسب ها ذخیره شد');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در ذخیره');
|
||||
}
|
||||
}
|
||||
|
||||
// ── slider
|
||||
async function saveSliders() {
|
||||
try {
|
||||
await api.put('const/mobile_main_slider', { val: sliders.value });
|
||||
message.success('اسلایدر ذخیره شد');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در ذخیره');
|
||||
}
|
||||
}
|
||||
|
||||
// ── collections
|
||||
async function loadCollections() {
|
||||
const { data } = await api.post('ciny', { page: 1, limit: 200 });
|
||||
collections.value = data.rows ?? data;
|
||||
}
|
||||
|
||||
async function addCollection() {
|
||||
if (!newCollection.value.trim()) return;
|
||||
try {
|
||||
if (collectionIsEdit.value) {
|
||||
await api.put(`ciny/${collectionEditId.value}`, { title: newCollection.value.trim() });
|
||||
} else {
|
||||
await api.post('ciny/create', { title: newCollection.value.trim() });
|
||||
}
|
||||
cancelCollectionEdit();
|
||||
await loadCollections();
|
||||
message.success('عملیات انجام شد');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا');
|
||||
}
|
||||
}
|
||||
|
||||
function editCollection(record: { id: string; title: string }) {
|
||||
collectionIsEdit.value = true;
|
||||
collectionEditId.value = record.id;
|
||||
newCollection.value = record.title;
|
||||
}
|
||||
|
||||
function cancelCollectionEdit() {
|
||||
collectionIsEdit.value = false;
|
||||
collectionEditId.value = undefined;
|
||||
newCollection.value = '';
|
||||
}
|
||||
|
||||
async function removeCollection(id: string) {
|
||||
try {
|
||||
await api.delete(`ciny/${id}`);
|
||||
await loadCollections();
|
||||
message.success('حذف شد');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در حذف');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await Promise.all([loadBrands(), loadCollections()]);
|
||||
const [t, s] = await Promise.all([
|
||||
api.get('const/__list__tags'),
|
||||
api.get('const/mobile_main_slider'),
|
||||
]);
|
||||
tags.value = Array.isArray(t.data) ? t.data : [];
|
||||
sliders.value = Array.isArray(s.data) ? s.data : [];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.slide {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.mobile-c {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/views/manage/report.vue — a shortcut board
|
||||
of report links, read from the `dashboard.links` setting.
|
||||
-->
|
||||
<a-card title="مدیریت گزارشات">
|
||||
<a-empty v-if="links.length === 0" description="هنوز لینکی تعریف نشده است" />
|
||||
<div v-else class="links">
|
||||
<a v-for="(l, i) in links" :key="i" :href="l.url" target="_blank" class="link-card">
|
||||
{{ l.title }}
|
||||
</a>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import api from '../../api/client';
|
||||
|
||||
const links = ref<{ title: string; url: string }[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get('dashboard/link');
|
||||
links.value = Array.isArray(data) ? data : [];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.links { display: flex; flex-wrap: wrap; gap: 1rem; }
|
||||
.link-card {
|
||||
display: block;
|
||||
padding: 1rem 1.5rem;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
color: #1c2d56;
|
||||
}
|
||||
.link-card:hover { border-color: #1c2d56; }
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/views/manage/wallet.vue — the wallet
|
||||
settings screen. Two tabs, each writing one settings key.
|
||||
|
||||
Legacy quirk preserved: the "تاریخ تولد" tab is declared FIRST in the markup
|
||||
but carries key="2", while "پس از خرید" carries key="1" and is the default
|
||||
active tab — so the visually-second tab opens first.
|
||||
-->
|
||||
<div>
|
||||
<a-card v-if="hasPermission('config.wallet')" title="مدیریت کیف پول">
|
||||
<a-tabs default-active-key="1">
|
||||
<a-tab-pane key="2" tab="تاریخ تولد" force-render>
|
||||
<a-spin :spinning="loading" tip="لطفا کمی تامل کنید">
|
||||
<a-form layout="vertical" @submit.prevent="setWallet2">
|
||||
<div class="grid-2">
|
||||
<a-form-item
|
||||
label="انقضا (روز)"
|
||||
:validate-status="err2.birthday_expire ? 'error' : ''"
|
||||
:help="err2.birthday_expire"
|
||||
>
|
||||
<a-input-number v-model:value="form2.birthday_expire" class="w-full" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="میزان (تومان)"
|
||||
:validate-status="err2.birthday_credit ? 'error' : ''"
|
||||
:help="err2.birthday_credit"
|
||||
>
|
||||
<a-input-number v-model:value="form2.birthday_credit" class="w-full" :min="0" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-button type="primary" class="mb-4 mt-4 mr-4" @click="setWallet2">ارسال</a-button>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="1" tab="پس از خرید" force-render>
|
||||
<a-spin :spinning="loading" tip="لطفا کمی تامل کنید">
|
||||
<a-form layout="vertical" @submit.prevent="setWallet1">
|
||||
<div class="grid-2">
|
||||
<a-form-item
|
||||
label="انقضا (روز)"
|
||||
:validate-status="err1.expire ? 'error' : ''"
|
||||
:help="err1.expire"
|
||||
>
|
||||
<a-input-number v-model:value="form1.expire" class="w-full" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="درصد"
|
||||
:validate-status="err1.percent ? 'error' : ''"
|
||||
:help="err1.percent"
|
||||
>
|
||||
<a-input-number
|
||||
v-model:value="form1.percent"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:formatter="(v: number | string) => `${v}%`"
|
||||
:parser="(v: string) => v.replace('%', '')"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-button type="primary" class="mb-4 mt-4 mr-4" @click="setWallet1">ارسال</a-button>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
import api from '../../api/client';
|
||||
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const form1 = reactive<Record<string, any>>({ expire: undefined, percent: undefined });
|
||||
const form2 = reactive<Record<string, any>>({
|
||||
birthday_expire: undefined,
|
||||
birthday_credit: undefined,
|
||||
});
|
||||
const err1 = reactive<Record<string, string>>({});
|
||||
const err2 = reactive<Record<string, string>>({});
|
||||
|
||||
const REQUIRED = 'لطفا مقدار را وارد کنید';
|
||||
|
||||
async function setWallet1() {
|
||||
Object.keys(err1).forEach((k) => delete err1[k]);
|
||||
if (form1.expire === undefined || form1.expire === null) err1.expire = REQUIRED;
|
||||
if (form1.percent === undefined || form1.percent === null) err1.percent = REQUIRED;
|
||||
if (Object.keys(err1).length) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
await api.post('wallet/config', { expire: form1.expire, percent: form1.percent });
|
||||
message.success('تغییرات ثبت شد');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در ثبت تغییرات');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setWallet2() {
|
||||
Object.keys(err2).forEach((k) => delete err2[k]);
|
||||
if (form2.birthday_expire === undefined || form2.birthday_expire === null) {
|
||||
err2.birthday_expire = REQUIRED;
|
||||
}
|
||||
if (form2.birthday_credit === undefined || form2.birthday_credit === null) {
|
||||
err2.birthday_credit = REQUIRED;
|
||||
}
|
||||
if (Object.keys(err2).length) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
await api.post('wallet/birthday/config', {
|
||||
birthday_expire: form2.birthday_expire,
|
||||
birthday_credit: form2.birthday_credit,
|
||||
});
|
||||
message.success('تغییرات ثبت شد');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در ثبت تغییرات');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [c1, c2] = await Promise.all([
|
||||
api.get('wallet/config'),
|
||||
api.get('wallet/birthday/config'),
|
||||
]);
|
||||
Object.assign(form1, c1.data ?? {});
|
||||
Object.assign(form2, c2.data ?? {});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user