Files
tehran_tala/apps/admin-panel/src/views/manage/Const.vue
T
s.esmaeiliandClaude Haiku 5.5 532ceee189 feat: move brand name to one config and redesign the accounting card
اپ: نام برند در همه‌ی صفحه‌ها و پیام‌ها یکسان شده و از یک تنظیم مرکزی خوانده می‌شود.
اپ: کارت حسابداری در صفحه‌ی مشتری بازطراحی شد؛ مانده‌ها و وضعیت لینک واضح‌تر نمایش داده می‌شوند.
پنل: کارت حسابداری در پرونده‌ی مشتری بازطراحی شد؛ مانده‌ها، آخرین ارسال و فرم لینک دستی واضح‌تر شده‌اند.
پنل: ستون «کد» در جدول‌ها به‌طور پیش‌فرض پنهان است و از انتخاب ستون‌ها قابل نمایش است.
پنل: تغییر نمایش ستون‌ها در جدول‌ها بلافاصله اعمال می‌شود.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-14 13:31:20 +03:30

472 lines
15 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="مدیریت ثابت ها" class="page-card table-scroll">
<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>
<!-- اسلایدر موبایل — دسترسی جدا از بقیه‌ی این صفحه، چون آپلود بنر عمومی
سایت حساس‌تر از ویرایش برند/برچسب است (custom.config.statics.slider) -->
<a-tab-pane
v-if="hasPermission('custom.config.statics.slider')"
key="slider"
tab="اسلایدر موبایل"
>
<div class="slider-toolbar">
<span class="slider-count">{{ sliders.length }} اسلاید</span>
<div class="row">
<a-button @click="addSlide">
<template #icon><PlusOutlined /></template>
افزودن اسلاید
</a-button>
<a-button type="primary" @click="saveSliders">ذخیره اسلایدر</a-button>
</div>
</div>
<a-empty v-if="sliders.length === 0" description="اسلایدی ثبت نشده است" />
<div v-else class="slider-grid">
<div v-for="(s, index) in sliders" :key="index" class="slide-card">
<div class="slide-card__head">
<span class="slide-card__title">اسلاید {{ index + 1 }}</span>
<a-popconfirm
ok-text="بله"
cancel-text="خیر"
title="این اسلاید حذف شود؟"
@confirm="removeSlide(index)"
>
<a-button type="text" danger size="small" title="حذف">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</div>
<div class="slide-card__media">
<ImageUploadInput v-model="s.image" purpose="slider" placeholder="تصویر اسلاید" />
</div>
<a-input v-model:value="s.link" placeholder="لینک" />
</div>
</div>
</a-tab-pane>
<!-- سینی‌ها — super admin only -->
<a-tab-pane v-if="users.isSuperAdmin" 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, PlusOutlined } 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
function addSlide() {
sliders.value.push({ image: '', link: '' });
}
function removeSlide(index: number) {
sliders.value.splice(index, 1);
}
// Gated server-side by its own `custom.config.statics.slider` permission (not
// the broad `config.statics` that guards the rest of this screen) — see
// admin-misc.controller.ts. Uses the dedicated `const/slider` route rather
// than the generic `const/:key`, which the server only accepts under that
// specific permission.
async function saveSliders() {
try {
await api.put('const/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(), users.isSuperAdmin ? loadCollections() : Promise.resolve()]);
const [t, s] = await Promise.all([
api.get('const/__list__tags'),
hasPermission('custom.config.statics.slider') ? api.get('const/slider') : Promise.resolve(null),
]);
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;
}
.slider-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.slider-count {
color: rgba(0, 0, 0, 0.45);
}
/* one card per slide; min() keeps a card no wider than a narrow phone column */
.slider-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr));
gap: 16px;
}
.slide-card {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
padding: 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fff;
}
.slide-card__head {
display: flex;
justify-content: space-between;
align-items: center;
}
.slide-card__title {
font-weight: 600;
}
/* Fixed-height media box: the empty upload tile, the uploading spinner and the
saved thumbnail all fill it, so the card keeps the same height when a photo
is added or removed (no jump). The shared upload tile is 104px square by
default; here it becomes a full-width banner preview. */
.slide-card__media {
height: 140px;
}
.slide-card__media :deep(.image-upload),
.slide-card__media :deep(.ant-upload-wrapper),
.slide-card__media :deep(.ant-upload-list),
.slide-card__media :deep(.ant-upload-list-item-container),
.slide-card__media :deep(.ant-upload.ant-upload-select) {
width: 100% !important;
height: 100% !important;
margin: 0 !important;
}
.slide-card__media :deep(.ant-upload-wrapper) {
display: block;
}
.slide-card__media :deep(.ant-upload-list-item-thumbnail img) {
object-fit: cover;
}
.mt-4 {
margin-top: 1rem;
}
@media (max-width: 575px) {
/* the add-row input fills what the button leaves instead of a fixed 260px */
.row > .ant-input {
flex: 1 1 140px;
max-width: none !important;
}
.mt-4 {
margin-top: 12px;
}
}
</style>