Files
tehran_tala/apps/admin-panel/src/views/manage/Const.vue
T
s.esmaeiliandClaude Sonnet 5 02d92fc465 admin-panel: add shared modal/header/icon/delete-handler components
New src/components/common/:
- BaseFormModal.vue — the standard shell every converted add/edit modal will
  use: header=title, footer=buttons, body=form. Replaces the ad-hoc
  60%/70%/80% <a-modal> widths used across the codebase with 4 named sizes.
- ListPageHeader.vue — the add button that goes above every list page,
  gated on the same rid the create route used (modelled on the one place
  this already existed, ListUser.vue's add-user button).

New src/components/icons.ts: the legacy-Feather-name -> ant-icon map,
extracted out of TooltipBtn.vue so the sidebar can resolve the same names
through the same map instead of a second copy.

New src/composables/useDeleteHandler.ts: the delete-then-refetch-then-toast
handler that was copy-pasted verbatim in 6 list views (city/cityList.vue,
user/ListUser.vue, apps/category/List.vue, finical/list.vue,
apps/product/ProductList.vue, manage/Const.vue). Preserves each site's exact
success-message wording and message.success/info choice, and ProductList's
irregular POST-based delete endpoint.

Verified: pnpm build passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 15:18:17 +03:30

374 lines
12 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import { useUsersStore } from '../../stores/users';
import { useDeleteHandler } from '../../composables/useDeleteHandler';
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 ?? 'خطا در ویرایش');
}
}
// refused while products reference it
const { remove: removeBrandById } = useDeleteHandler({
onSuccess: loadBrands,
successMessage: 'برند حذف شد',
fallbackErrorMessage: 'خطا در حذف برند',
});
function removeBrand(id: string) {
return removeBrandById(`brand/${id}`);
}
// ── 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>