add reygiri page

This commit is contained in:
2026-07-12 17:51:48 +02:00
parent b8bdf8228c
commit b2e59464a4
6 changed files with 410 additions and 5 deletions
-4
View File
@@ -10,7 +10,6 @@ export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
_index: typeof import('./src/core/components/_sample/_index.vue')['default']
AAlert: typeof import('ant-design-vue/es')['Alert']
AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button']
@@ -43,7 +42,6 @@ declare module '@vue/runtime-core' {
APagination: typeof import('ant-design-vue/es')['Pagination']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
APopover: typeof import('ant-design-vue/es')['Popover']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARow: typeof import('ant-design-vue/es')['Row']
@@ -55,11 +53,9 @@ declare module '@vue/runtime-core' {
ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimePicker: typeof import('ant-design-vue/es')['TimePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
AUpload: typeof import('ant-design-vue/es')['Upload']
BaseButton: typeof import('./src/core/components/BaseButton/index.ts')['default']
BaseCard: typeof import('./src/core/components/BaseCard/index.ts')['default']
BaseCollapse: typeof import('./src/components/BaseCollapse.vue')['default']
+7
View File
@@ -25,3 +25,10 @@ export const updateBannerConst = async (obj: Const) =>
export const getHeaderBannerConst = async () =>
await useAxios.get<Const>('/api/const/key/header_banner')
/** GET /api/const/key/reygiri_sites */
export const getReygiriSitesConst = async () =>
await useAxios.get<Const | Array<Const>>('/api/const/key/reygiri_sites')
export const updateReygiriSitesConst = async (obj: Const) =>
await useAxios.put<Const>(`/admin/const/${obj.id}`, obj)
+5
View File
@@ -0,0 +1,5 @@
export interface CaptureSite {
id: string
name: string
url: string
}
+338
View File
@@ -0,0 +1,338 @@
<template>
<div class="capture-sites-page">
<BasicShowContext title="لیست سایت‌های ری‌گیری">
<template #extra>
<BaseButton type="primary" size="small" :disabled="loading" @click="openAdd">
<span>افزودن سایت</span>
<BaseIcon icon="vuesax-linear:add" size="16" />
</BaseButton>
</template>
<a-spin :spinning="loading">
<a-empty
v-if="!sites.length"
description="هنوز سایتی ثبت نشده"
class="capture-sites-page__empty"
/>
<ul v-else class="capture-sites-list">
<li v-for="(site, index) in sites" :key="site.id" class="capture-sites-list__item">
<div class="capture-sites-list__name">{{ site.name }}</div>
<a
class="capture-sites-list__url"
:href="resolveHref(site.url)"
target="_blank"
rel="noopener noreferrer"
>
{{ site.url }}
</a>
<div class="capture-sites-list__actions">
<a-tooltip placement="bottom">
<template #title>ویرایش</template>
<Icon
icon="vuesax-linear:edit"
color="#4572E9"
class="cursor-pointer"
@click="openEdit(index)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title>حذف</template>
<Icon
icon="vuesax-linear:trash"
color="#C53030"
class="cursor-pointer"
@click="confirmDelete(index)"
/>
</a-tooltip>
</div>
</li>
</ul>
</a-spin>
</BasicShowContext>
<BasicModal
v-model:visible="editorVisible"
:title="editorTitle"
width="28rem"
ok-text="ثبت"
cancel-text="انصراف"
:confirm-loading="saving"
@ok="submitForm"
@close="closeEditor"
>
<a-form ref="formRef" :model="form" layout="vertical" autocomplete="off">
<a-form-item label="نام سایت" name="name" :rules="inputRules('نام سایت')">
<BasicInput v-model:value="form.name" placeHolder="مثال: سایت ریگیری طلا" />
</a-form-item>
<a-form-item label="لینک سایت" name="url" :rules="urlRules">
<BasicInput v-model:value="form.url" placeHolder="https://example.com" class="capture-sites-page__url-input" />
</a-form-item>
</a-form>
</BasicModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Modal, message } from 'ant-design-vue'
import { createVNode } from 'vue'
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'
import type { FormInstance } from 'ant-design-vue'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import BasicShowContext from '@/components/BasicShowContext.vue'
import BasicModal from '@/components/BasicModal.vue'
import type { CaptureSite } from '@/model/captureSite'
import {
createAddConst,
getReygiriSitesConst,
updateReygiriSitesConst,
} from '@/api/consts'
import { inputRules } from '@/utils/inputRules'
import {
REYGIri_SITES_KEY,
normalizeReygiriSitesConst,
serializeReygiriSites,
} from '@/utils/reygiriSites'
const sites = ref<CaptureSite[]>([])
const loading = ref(false)
const saving = ref(false)
const editorVisible = ref(false)
const editIndex = ref<number | null>(null)
const constId = ref<number | null>(null)
const formRef = ref<FormInstance>()
const form = ref({
name: '',
url: '',
})
const editorTitle = computed(() =>
editIndex.value == null ? 'افزودن سایت' : 'ویرایش سایت'
)
const urlRules = [
{ required: true, message: 'پر کردن لینک سایت الزامی است' },
{
validator(_rule: unknown, value: string) {
const trimmed = (value ?? '').trim()
if (!trimmed) {
return Promise.reject('پر کردن لینک سایت الزامی است')
}
if (/\s/.test(trimmed)) {
return Promise.reject('لینک سایت نباید شامل فاصله باشد')
}
return Promise.resolve()
},
trigger: ['change', 'blur'],
},
]
function resolveHref(url: string): string {
const trimmed = url.trim()
if (!trimmed) return '#'
if (/^https?:\/\//i.test(trimmed)) return trimmed
return `https://${trimmed.replace(/^\/+/, '')}`
}
function createSiteId() {
return `site-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function resetForm() {
form.value = { name: '', url: '' }
formRef.value?.clearValidate()
}
function openAdd() {
editIndex.value = null
resetForm()
editorVisible.value = true
}
function openEdit(index: number) {
const site = sites.value[index]
if (!site) return
editIndex.value = index
form.value = {
name: site.name,
url: site.url,
}
editorVisible.value = true
}
function closeEditor() {
editorVisible.value = false
editIndex.value = null
resetForm()
}
async function persistSites() {
const payload = {
key: REYGIri_SITES_KEY,
value: serializeReygiriSites(sites.value),
is_active: true,
description: 'لیست سایت‌های ری‌گیری',
}
if (constId.value) {
await updateReygiriSitesConst({ id: constId.value, ...payload })
return
}
const created = await createAddConst(payload)
constId.value = created.id ?? created.const_id ?? null
}
async function submitForm() {
try {
await formRef.value?.validate()
saving.value = true
const nextSite: CaptureSite = {
id: editIndex.value == null ? createSiteId() : sites.value[editIndex.value].id,
name: form.value.name.trim(),
url: form.value.url.trim(),
}
if (editIndex.value == null) {
sites.value.push(nextSite)
} else {
sites.value[editIndex.value] = nextSite
}
await persistSites()
message.success(editIndex.value == null ? 'سایت با موفقیت افزوده شد' : 'سایت با موفقیت ویرایش شد')
closeEditor()
} catch (error) {
console.error(error)
} finally {
saving.value = false
}
}
function confirmDelete(index: number) {
const site = sites.value[index]
if (!site) return
Modal.confirm({
title: `آیا از حذف «${site.name}» اطمینان دارید؟`,
icon: createVNode(ExclamationCircleOutlined),
okText: 'حذف',
okType: 'danger',
centered: true,
cancelText: 'انصراف',
async onOk() {
sites.value.splice(index, 1)
saving.value = true
try {
await persistSites()
message.success('سایت با موفقیت حذف شد')
} catch (error) {
console.error(error)
message.error('خطا در حذف سایت')
} finally {
saving.value = false
}
},
})
}
async function loadSites() {
loading.value = true
try {
const res = await getReygiriSitesConst()
const normalized = normalizeReygiriSitesConst(res)
constId.value = normalized.constId
sites.value = normalized.sites
} catch (error) {
console.error('خطا در دریافت سایت‌های ری‌گیری:', error)
message.error('خطا در دریافت لیست سایت‌های ری‌گیری')
sites.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
void loadSites()
})
</script>
<style scoped lang="less">
.capture-sites-page {
display: flex;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
}
.capture-sites-page__empty {
margin-top: 3rem;
}
:deep(.capture-sites-page__url-input .ant-input) {
direction: ltr;
text-align: left;
}
.capture-sites-list {
margin: 0;
padding: 0;
list-style: none;
border: 1px solid var(--admin-border, #e5e7eb);
border-radius: 12px;
overflow: hidden;
background: var(--admin-bg-elevated, #fff);
}
.capture-sites-list__item {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) auto;
align-items: center;
gap: 1rem;
padding: 0.9rem 1rem;
border-bottom: 1px solid var(--admin-border, #e5e7eb);
&:last-child {
border-bottom: none;
}
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
.capture-sites-list__name {
font-size: 0.9375rem;
font-weight: 600;
color: var(--admin-text, #111827);
line-height: 1.6;
}
.capture-sites-list__url {
justify-self: start;
direction: ltr;
text-align: left;
font-size: 0.875rem;
color: #4572e9;
word-break: break-all;
&:hover {
text-decoration: underline;
}
}
.capture-sites-list__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
</style>
+11
View File
@@ -54,6 +54,17 @@ const Setting: RouteRecordRaw = {
order: 2
}
},
{
name: 'captureSitesMain',
path: 'capture-sites',
component: () => import('@/pages/menuPages/captureSites/index.vue'),
meta: {
permissionsKey: 'pricing',
icon: 'vuesax-linear:global',
title: 'سایت‌های ری‌گیری',
order: 2.5
}
},
{
name: 'advancedConfiguration',
path: 'advanced-configuration',
+48
View File
@@ -0,0 +1,48 @@
import type { Const } from '@/model/const'
import type { CaptureSite } from '@/model/captureSite'
export const REYGIri_SITES_KEY = 'reygiri_sites'
export function normalizeReygiriSitesConst(response: Const | Const[] | null | undefined): {
constId: number | null
sites: CaptureSite[]
} {
const record = Array.isArray(response) ? response[0] : response
if (!record) {
return { constId: null, sites: [] }
}
return {
constId: record.id ?? record.const_id ?? null,
sites: parseReygiriSitesValue(record.value),
}
}
export function parseReygiriSitesValue(value: string | null | undefined): CaptureSite[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed)) return []
return parsed
.map((item, index) => ({
id: String(item?.id ?? `site-${index + 1}`),
name: String(item?.name ?? item?.title ?? item?.title_fa ?? item?.caption ?? '').trim(),
url: String(item?.url ?? item?.link ?? item?.value ?? '').trim(),
}))
.filter((item) => item.name && item.url)
} catch {
return []
}
}
export function serializeReygiriSites(sites: CaptureSite[]): string {
return JSON.stringify(
sites.map(({ id, name, url }) => ({
id,
name,
url,
}))
)
}