This commit is contained in:
Baghi330 2026-02-14 10:41:53 +03:30
parent cc647ffaba
commit 7892a7cefb
31 changed files with 5983 additions and 2006 deletions

2
.env
View File

@ -3,5 +3,5 @@ NUXT_PUBLIC_API_NAME=api/
IS_DEVLOP_MODE=1
NUXT_PUBLIC_SYSTEM=monir
NUXT_PUBLIC_BASE_URL=http://192.168.23.60/
NUXT_PUBLIC_BASE_URL=http://192.168.23.160/
NUXT_PUBLIC_BASE_URL2=https://hamfahmi.ir/

View File

@ -3,3 +3,4 @@ NUXT_PUBLIC_SYSTEM=majles
# (اختیاری)
NUXT_PUBLIC_APP_NAME=Majles System
NUXT_PUBLIC_BASE_URL=http://192.168.23.160/

27
app/apis/elpApi.js Executable file
View File

@ -0,0 +1,27 @@
export default {
base: {
update_field: "elp/v1/indices/{{type_name}}/update/field/{{doc_id}}",
compute_field: "elp/v1/indices/{{type_name}}/compute/field",
},
search: {
elp_base_search: "elp/v1/indices/{{index_key}}/search",
elp_voice_search: "elp/voice/{{index_key}}/search",
elp_db_search: "elp/db/indices/{{index_key}}/search",
},
Info: {
elp_completion: "elp/v1/indices/{{type_name}}/completion/{{property_key}}",
elp_main_label: "elp/v1/base/option/get/{{main_label}}",
elp_update_field: "elp/v1/indices/{{type_name}}/update/field/{{doc_id}}",
elp_list_Tree: "/elp/tbase/{{type_name}}/list/tree",
elp_get_Tree: "/elp/tbase/{{type_name}}/get/item/all",
elp_update_Tree: "/elp/tbase/{{type_name}}/update/{{id}}",
elp_delete_Tree: "/elp/tbase/{{type_name}}/delete/{{id}}",
elp_move_in_Tree:
"/elp/tbase/{{type_name}}/move_in/{{drop_id}}/{{drag_id}}",
elp_insert_Tree: "/elp/tbase/{{type_name}}/insert",
elp_relation_Tree: "/elp/tbase/{{type_name}}/relation/add",
},
data_entry:{
getSchema:"elp/schema/get"
}
};

20
app/apis/permitApi.js Executable file
View File

@ -0,0 +1,20 @@
export default {
roles: {
list: "role/list",
add: "role/add",
edit: "role/edit",
delete: "role/del",
},
permissions: {
addOrEditUserPermission: "uperm/add",
addOrEditRolePermission: "rperm/add",
addOrEditLoginRolePermission: "rperm/login/add",
deleteUserPermission: "uperm/del",
deleteRolePermission: "rperm/del",
listUserPermission: "uperm/list",
listRolePermission: "rperm/list",
listUserRole: "rperm/listuser",
deleteUserRole: "urole/del",
userPermissionTags: "permit/uperm/list/tags",
},
};

31
app/apis/repoApi.js Executable file
View File

@ -0,0 +1,31 @@
export default {
search: {
mirror_search:
"repo/monir/mirror/{{mirror_type}}/{{index_key}}/{{search_type}}/{{sortKey}}/{{field_collapse}}/{{offset}}/{{limit}}/{{filter}}",
autoComplate: "monir/complation/{{index_key}}/{{filter}}",
queryNormal:
"repo/monir/search/{{index_key}}/{{search_type}}/{{sortKey}}/{{field_collapse}}/{{offset}}/{{limit}}/{{filter}}",
textSearch:
"repo/monir/search/text/{{index_key}}/{{field}}/{{offset}}/{{limit}}/{{filter}}",
// queryMirror:
// "monir/mirror/{{mirror_type}}/{{index_key}}/{{search_type}}/{{sortKey}}/{{field_collapse}}/{{offset}}/{{limit}}/{{filter}}",
},
public: {
updateProperty_full:
"public/{{index_key}}/edit/{{entity_id}}/{{property}}/index/full",
updateEntity: "repo/public/{{index_key}}/update/{{id}}",
get_doc_byid: "repo/public/get/byid/{{index_key}}/{{entity_id}}",
},
calendar: {
schema: {
list: "repo/schema",
},
list: "repo/calendar/@offset/@limit/@filter",
list_collpase: "repo/calendar/@field_collapsed/@offset/@limit/@filter",
addTask: "repo/calendar/add",
update: "repo/calendar/edit/@id",
delete: "repo/calendar/delete/@id",
},
};

View File

@ -38,7 +38,7 @@
</template>
<script setup>
import { useConfirmState, useConfirmActions } from "~/composables/useConfirm";
import { useConfirmState, useConfirmActions } from "@/composables/useConfirm";
const state = useConfirmState();
const actions = useConfirmActions();

View File

@ -7,6 +7,7 @@
:items="normalizedItems"
:multiple="selectSchema.multiple"
:searchable="selectSchema.searchable"
:search-input="selectSchema.searchInput"
:creatable="selectSchema.creatable"
:placeholder="selectSchema.placeholder"
:option-attribute="selectSchema.optionAttribute"
@ -49,6 +50,7 @@
:items="normalizedItems"
:multiple="selectSchema.multiple"
:searchable="selectSchema.searchable"
:search-input="selectSchema.searchInput"
:creatable="selectSchema.creatable"
:loading="isLoading"
:placeholder="selectSchema.placeholder"
@ -119,6 +121,7 @@ const selectSchema = computed(() => {
multiple:
i.multiple === true || i.multi_select === 1 || i.multi_select === true,
searchable: i.searchable ?? true,
searchInput: i.searchInput ?? true,
creatable: i.creatable ?? i.allowCreate ?? false,
loading: i.loading ?? false,
placeholder: i.placeholder || "لطفاً انتخاب کنید...",

View File

@ -0,0 +1,501 @@
<template>
<div :ref="selectedTabDetails" class="w-full">
<div class="container-fluid max-w-full px-3">
<div class="row">
<div class="col-12 px-3">
<div
class="mt-3 mb-1 flex h-auto items-center justify-between bg-gray-100 dark:bg-dark-primary-800 rounded-md p-1"
>
<div class="w-full">
<div
v-for="(headerItems, index) in headerTools"
:key="index"
class="flex justify-between items-center my-2"
>
<div class="flex justify-between w-full flex-wrap px-2">
<div
v-for="(my_item, idx) in getArrayItems(headerItems)"
:key="idx"
class="flex items-center flex-wrap"
>
<template v-if="isArrayItems(my_item)">
<div
v-for="(headItem, myItemIndex) in getArrayItems(
my_item,
)"
:key="myItemIndex"
class="flex items-center"
>
<DropdownSetting
v-if="headItem.type === 'dropdownSetting'"
:schema="headItem"
@dropdown-setting-btn-clicked="
(val) =>
emitHandler('dropdown-setting', { data: val })
"
/>
<div
v-else-if="headItem.key === 'label'"
class="mr-3"
v-tooltip="headItem.tooltip || ''"
>
<span class="text-primary font-medium px-3">{{
headItem.label
}}</span>
</div>
<div
v-else-if="headItem.key === 'text'"
class="mr-3"
v-tooltip="headItem.tooltip || ''"
>
<span class="text-primary font-medium px-3"
>{{ headItem.label }}:</span
>
<span>{{ getDataValue(headItem.source_key) }}</span>
</div>
<div v-else-if="headItem.key === 'switch'" class="mr-2">
<SwitchButtons
:switchSchema="headItem.switchSchema"
@update:model-value="
(val) =>
emitHandler('switch-component-change', {
data: {
name: headItem.name,
value: val,
item: headItem,
},
})
"
/>
</div>
<div
v-else-if="headItem.key === 'range'"
class="flex items-center mx-1"
>
<label
v-if="headItem.label"
class="text-sm text-gray-600 me-2"
>
{{ headItem.label }}
</label>
<input
type="range"
class="form-range appearance-none h-1.5 w-24 bg-gray-200 rounded-lg outline-none"
:min="headItem.min || 0"
:max="headItem.max || 5"
:step="headItem.step || 1"
:value="headItem.value"
@input="(e) => handleRange(e, headItem)"
/>
<span class="ms-2 text-sm min-w-[18px] text-center">{{
headItem.value
}}</span>
</div>
<button
v-else-if="headItem.key === 'icon'"
class="btn p-2 rounded hover:text-primary transition-colors"
:title="headItem.label"
v-tooltip="headItem.tooltip || ''"
@click="emitHandler('icon-click', { data: headItem })"
>
<!-- <svg :class="'icon icon-' + headItem.icon">
<use :xlink:href="'#icon-' + headItem.icon" />
</svg> -->
<UIcon
:name="headItem.icon"
:class="headItem.size"
class="cursor-pointer"
/>
</button>
<div
v-else-if="
headItem.key === 'multiSelect' ||
headItem.key === 'dropdown'
"
class="mx-1"
>
<MySelect
v-model:dropdownSchema="headItem.dropdownSchema"
:selectSchema="headItem.dropdownSchema"
@dropdownSelectEvents="
(event) => {
if (!event) return;
if (event.action !== 'change') return;
emitHandler('multiselect-click', {
data: {
name: headItem.name,
value: event.payload,
item: headItem,
},
});
}
"
/>
</div>
<div
v-else-if="headItem.key === 'button'"
v-tooltip="headItem.tooltip || ''"
>
<button
class="btn bg-primary text-white px-3 py-1 rounded hover:bg-primary-dark"
@click="
emitHandler('button-click', { data: headItem })
"
>
{{ headItem.label }}
</button>
</div>
<div
v-else-if="headItem.key === 'iconButton'"
v-tooltip="headItem.tooltip || ''"
>
<button
class="btn flex items-center gap-1 px-2 py-1 rounded hover:text-primary"
@click="
emitHandler('iconButton-click', {
data: headItem,
})
"
:style="{ color: headItem.color }"
>
<svg
v-if="headItem.side === 'right'"
:class="'icon icon-' + headItem.icon"
>
<use :xlink:href="'#icon-' + headItem.icon" />
</svg>
{{ headItem.label }}
<svg
v-if="headItem.side === 'left'"
:class="'icon icon-' + headItem.icon"
>
<use :xlink:href="'#icon-' + headItem.icon" />
</svg>
</button>
</div>
<div
v-else-if="
headItem.key === 'rangeDate' && !headItem.isShow
"
>
<RangeDateToolsHeader
@date-picker-handler="
(val) => emitHandler('date-picker', { data: val })
"
/>
</div>
<div v-else-if="headItem.key === 'autoComplation'">
<AutoComplation
@auto-complation-handler="
(val) =>
emitHandler('auto-complation', { data: val })
"
:autoComplationSchema="{
placeholder: headItem.placeholder,
autocompleteUrl: headItem.autocompleteUrl,
debounceTime: headItem.debounceTime,
minCharsForAutocomplete:
headItem.minCharsForAutocomplete,
maxHistoryItems: headItem.maxHistoryItems,
showSearchButton: headItem.showSearchButton,
filters: headItem.filters,
}"
/>
</div>
<div
v-else-if="headItem.key === 'prevNext'"
class="flex items-center gap-2"
>
<button
class="flex items-center btn bg-primary text-white pl-2 py-1 rounded hover:bg-primary-700 transition-colors duration-300 ease-in-out disabled:cursor-not-allowed"
:disabled="headItem.prevDisabled"
@click="
emitHandler('prev-click', { data: headItem })
"
>
<UIcon
name="i-lucide-chevron-right"
class="w-6 h-6"
/>
<span>قبلی</span>
</button>
<button
class="flex items-center btn bg-primary text-white pr-2 py-1 rounded hover:bg-primary-700 transition-colors duration-300 ease-in-out disabled:cursor-not-allowed"
:disabled="headItem.nextDisabled"
@click="
emitHandler('next-click', { data: headItem })
"
>
<span>بعدی</span>
<UIcon
name="i-lucide-chevron-left"
class="w-6 h-6"
/>
</button>
</div>
</div>
</template>
<!-- Non-array items -->
<template v-else>
<DropdownSetting
v-if="my_item.type === 'dropdownSetting'"
:schema="my_item"
@dropdown-setting-btn-clicked="
(val) =>
emitHandler('dropdown-setting', { data: val })
"
/>
<div
v-else-if="my_item.key === 'label'"
class="mr-3"
v-tooltip="my_item.tooltip || ''"
>
<span class="text-primary font-medium px-3">{{
my_item.label
}}</span>
</div>
<div
v-else-if="my_item.key === 'text'"
class="mr-3"
v-tooltip="my_item.tooltip || ''"
>
<span class="text-primary font-medium px-3"
>{{ my_item.label }}:</span
>
<span>{{ getDataValue(my_item.source_key) }}</span>
</div>
<!-- جایگزینی سوئیچ قدیمی -->
<div v-else-if="my_item.key === 'switch'" class="mr-2">
<SwitchButtons
:switchSchema="my_item.switchSchema"
@update:model-value="
(val) =>
emitHandler('switch-component-change', {
data: {
name: my_item.name,
value: val,
item: my_item,
},
})
"
/>
</div>
<div
v-else-if="my_item.key === 'range'"
class="flex items-center mx-1"
>
<label
v-if="my_item.label"
class="text-sm text-gray-600 me-2"
>
{{ my_item.label }}
</label>
<input
type="range"
class="form-range appearance-none h-1.5 w-24 bg-gray-200 rounded-lg outline-none"
:min="my_item.min || 0"
:max="my_item.max || 5"
:step="my_item.step || 1"
:value="my_item.value"
@input="(e) => handleRange(e, my_item)"
/>
<span class="ms-2 text-sm min-w-[18px] text-center">{{
my_item.value
}}</span>
</div>
<button
v-else-if="my_item.key === 'icon'"
class="btn p-2 rounded hover:text-primary transition-colors"
:title="my_item.label"
v-tooltip="my_item.tooltip || ''"
@click="emitHandler('icon-click', { data: my_item })"
>
<!-- <svg :class="'icon icon-' + my_item.icon">
<use :xlink:href="'#icon-' + my_item.icon" />
</svg> -->
<UIcon :name="my_item.icon" :class="my_item.size" />
</button>
<div
v-else-if="
my_item.key === 'multiSelect' ||
my_item.key === 'dropdown'
"
class="mx-1"
>
<MySelect
v-model:dropdownSelectConfig="my_item.dropdownSchema"
:selectSchema="my_item.dropdownSchema"
@dropdownSelectEvents="
(event) => {
if (event.action !== 'change') return;
emitHandler('multiselect-click', {
data: {
name: my_item.name,
value: event.payload,
item: my_item,
},
});
}
"
/>
</div>
<div
v-else-if="my_item.key === 'button'"
v-tooltip="my_item.tooltip || ''"
>
<button
class="btn bg-primary text-white px-3 py-1 rounded hover:bg-primary-dark"
@click="
emitHandler('button-click', { data: my_item })
"
>
{{ my_item.label }}
</button>
</div>
<div
v-else-if="my_item.key === 'iconButton'"
v-tooltip="my_item.tooltip || ''"
>
<button
class="btn flex items-center gap-1 px-2 py-1 rounded hover:text-primary"
@click="
emitHandler('iconButton-click', { data: my_item })
"
:style="{ color: my_item.color }"
>
<svg
v-if="my_item.side === 'right'"
:class="'icon icon-' + my_item.icon"
>
<use :xlink:href="'#icon-' + my_item.icon" />
</svg>
{{ my_item.label }}
<svg
v-if="my_item.side === 'left'"
:class="'icon icon-' + my_item.icon"
>
<use :xlink:href="'#icon-' + my_item.icon" />
</svg>
</button>
</div>
<div
v-else-if="
my_item.key === 'rangeDate' && !my_item.isShow
"
>
<RangeDateToolsHeader
@date-picker-handler="
(val) => emitHandler('date-picker', { data: val })
"
/>
</div>
<div v-else-if="my_item.key === 'autoComplation'">
<AutoComplation
@auto-complation-handler="
(val) =>
emitHandler('auto-complation', { data: val })
"
:autoComplationSchema="{
autocompleteUrl: my_item.autocompleteUrl,
placeholder: my_item.placeholder,
debounceTime: my_item.debounceTime,
minCharsForAutocomplete:
my_item.minCharsForAutocomplete,
maxHistoryItems: my_item.maxHistoryItems,
showSearchButton: my_item.showSearchButton,
filters: my_item.filters,
}"
/>
</div>
<div
v-else-if="my_item.key === 'prevNext'"
class="flex items-center gap-2"
>
<button
class="flex items-center btn bg-primary text-white pl-2 py-1 rounded hover:bg-primary-700 transition-colors duration-300 ease-in-out disabled:cursor-not-allowed"
:disabled="my_item.prevDisabled"
@click="emitHandler('prev-click', { data: my_item })"
>
<UIcon
name="i-lucide-chevron-right"
class="w-6 h-6"
/>
<span>قبلی</span>
</button>
<button
class="flex items-center btn bg-primary text-white pr-2 py-1 rounded hover:bg-primary-700 transition-colors duration-300 ease-in-out disabled:cursor-not-allowed"
:disabled="my_item.nextDisabled"
@click="emitHandler('next-click', { data: my_item })"
>
<span>بعدی</span>
<UIcon name="i-lucide-chevron-left" class="w-6 h-6" />
</button>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
const props = defineProps({
headerTools: { type: Array, default: () => [] },
entity: { type: Object, default: () => ({}) },
});
const emit = defineEmits(["header-tools-action"]);
function emitHandler(action, payload) {
emit("header-tools-action", {
action,
data: payload.data,
});
}
const selectedTabDetails = ref(null);
const localEntity = computed(() => props.entity || {});
function isArrayItems(item) {
return Array.isArray(item) || (item && item.items && !item.type);
}
function getArrayItems(item) {
return item.items ? item.items : Array.isArray(item) ? item : [];
}
function getDataValue(key) {
if (!localEntity.value) return "--";
const _source = localEntity.value._source || localEntity.value;
let res = "";
const keys = key.split("/");
for (const k of keys) {
const kv = k.split(".");
const value = kv.length === 2 ? _source[kv[0]]?.[kv[1]] : _source[k];
if (value) {
res = res ? `${res} / ${value}` : value;
}
}
return res || _source[key] || _source.qanon_title || _source.title || "--";
}
function handleRange(event, item) {
const value = parseInt(event.target.value, 10);
item.value = value;
emitHandler("range-change", { data: { name: item.name, value, item } });
}
</script>

View File

@ -0,0 +1,191 @@
<template>
<div class="my-select">
<USelect
v-model="selectedValue"
:items="formattedItems"
:placeholder="selectSchema.placeholder || 'انتخاب کنید'"
:multiple="selectSchema.multiple || false"
:disabled="selectSchema.disabled || loading"
:loading="loading"
@update:modelValue="onModelUpdate"
:class="[gridColumnClass, 'cursor-pointer']"
:style="selectWidthStyle"
:ui="{ wrapper: 'w-full', base: 'w-full' }"
dir="rtl"
color="primary"
size="xl"
highlight
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { useCachedRequest } from "@/composables/useCachedRequest";
const props = defineProps({
selectSchema: { type: Object, default: () => ({}) },
gridColumnClass: { type: String, default: "" },
});
const emit = defineEmits(["my-select-action"]);
const emitAction = (action, payload) => {
emit("my-select-action", { action, payload });
};
const selectedValue = ref(null);
const rawItems = ref([]);
const loading = ref(false);
const lastEmittedValue = ref(null);
const { fetchRequest } = useCachedRequest();
const mode = computed(() => props.selectSchema.mode ?? "local");
const formattedItems = computed(() => {
const labelKey = props.selectSchema.labelKey || "name";
const valueKey = props.selectSchema.valueKey || "id";
return rawItems.value.map((item) => {
if (typeof item === "string") return { label: item, value: item };
return {
label:
item[labelKey] ?? item.name ?? item.title ?? item.label ?? "بدون عنوان",
value: item[valueKey] ?? item.id ?? item._id ?? item.value,
};
});
});
const selectWidthStyle = computed(() => {
return props.selectSchema.width ? { width: props.selectSchema.width } : {};
});
// مقایسه امن برای جلوگیری از emit تکراری
const isSameValue = (a, b) => {
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((v, i) => v === b[i]);
}
return a === b;
};
const emitSelected = (val) => {
if (isSameValue(val, lastEmittedValue.value)) return;
lastEmittedValue.value = Array.isArray(val) ? [...val] : val;
const selectedItem = formattedItems.value.find((i) =>
props.selectSchema.multiple ? val?.includes(i.value) : i.value === val,
);
emitAction("selected", selectedItem);
};
const syncDefaultSelection = () => {
if (props.selectSchema.value != null) return;
if (!formattedItems.value.length) return;
const isEmpty =
selectedValue.value == null ||
(Array.isArray(selectedValue.value) && !selectedValue.value.length);
if (isEmpty) {
const firstItem = formattedItems.value[0];
const newVal = props.selectSchema.multiple
? [firstItem.value]
: firstItem.value;
selectedValue.value = newVal;
emitSelected(newVal);
}
};
const onModelUpdate = (val) => {
emitSelected(val);
};
const fetchItems = async () => {
if (mode.value !== "api") {
rawItems.value =
props.selectSchema.options ?? props.selectSchema.items ?? [];
syncDefaultSelection();
return;
}
const apiConfig = props.selectSchema.apiConfig || {};
if (!apiConfig.url) return;
loading.value = true;
try {
const data = await fetchRequest(apiConfig);
rawItems.value = Array.isArray(data) ? data : (data?.data ?? []);
syncDefaultSelection();
emitAction("fetch:success", { items: rawItems.value });
} catch (error) {
emitAction("fetch:error", { error: error?.message ?? error });
} finally {
loading.value = false;
}
};
watch(
() => [props.selectSchema.items, props.selectSchema.options],
() => {
if (mode.value !== "api") {
rawItems.value =
props.selectSchema.options ?? props.selectSchema.items ?? [];
syncDefaultSelection();
}
},
{ immediate: true, deep: true },
);
watch(
() => props.selectSchema.value,
(val) => {
selectedValue.value = val ?? null;
emitSelected(val);
},
{ immediate: true },
);
onMounted(fetchItems);
</script>
<style lang="scss">
.my-select {
button[role="combobox"] {
direction: rtl; /* راست‌چین کردن متن و placeholder */
text-align: right; /* ترازبندی متن داخل button */
}
/* 2. placeholder */
button[role="combobox"] [data-slot="placeholder"] {
text-align: right;
direction: rtl;
}
/* 3. آیکون پایین (chevron) */
button[role="combobox"] [data-slot="trailing"] {
right: auto; /* اگر نیاز باشد موقعیت icon را اصلاح کنید */
left: 0; /* icon به سمت چپ می‌رود */
}
/* 4. منوی بازشونده (اگر teleport شده باشد) */
/* کلاس واقعی منو را از inspector پیدا کنید و به جای .u-select__menu بنویسید */
.u-select__menu {
direction: rtl !important;
text-align: right !important;
}
/* 5. آیتم‌های منو */
.u-select__menu [data-slot="item"] {
text-align: right;
direction: rtl;
display: flex;
flex-direction: row-reverse; /* اگر icon ها در آیتم هستند آنها را برعکس می‌کند */
}
}
.i-lucide\:check {
color: #22c55e;
stroke: #22c55e;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,80 @@
<template>
<h1>main list</h1>
<div>
<HeaderTools
:header-tools="headerTools"
@header-tools-action="headerToolsAction"
></HeaderTools>
<div class="p-4 pt-0 w-full lg:flex-1">
<MyContent
:mainSchema="myContentSchema"
:pagination="pagination"
@my-content-action="myContentAction"
/>
</div>
</div>
</template>
<script>
export default {};
<script setup>
import sampelDataDb from "@/json/data-entry/sampelDataDb.json";
import refineCodes from "@/json/refineCodes.json";
import MyContent from "@/components/lazy-load/global/MyContent.vue";
const { $http: httpService } = useNuxtApp();
const props = defineProps({
listConflicts: {
type: Array,
default: () => [],
},
});
const pagination = ref({
total: 0,
page: 1,
limit: 10,
});
let myContentSchema = {};
myContentSchema = ref(JSON.parse(JSON.stringify(sampelDataDb)));
myContentSchema.value.items = props.listConflicts.hits.hits;
myContentSchema.value.pagination = {
total: props.listConflicts.hits.total.value,
page: pagination.value.page,
limit: pagination.value.limit,
};
const headerTools = computed(() => [
{
items: [
{
type: "dropdown",
key: "dropdown",
name: "refine_codes",
dropdownSchema: {
width: "18em",
modelValue: null, // اینو بذار
optionAttribute: "title",
valueAttribute: "value",
searchable: false,
placeholder: "انتخاب کنید",
items: refineCodes,
},
},
{
key: "prevNext",
name: "entityNavigator",
prevDisabled: false,
nextDisabled: true,
},
],
},
]);
function headerToolsAction({ action, data }) {
if (action === "prev-click") {
}
if (action === "next-click") {
}
}
function myContentAction({ action, data }) {
console.log(action, data);
}
</script>
<style></style>
<style lang="scss"></style>

View File

@ -1,11 +1,26 @@
<template>
<h1>RelationEdit</h1>
<TiptapEditor></TiptapEditor>
<div>
<HeaderTools></HeaderTools>
<TiptapEditor :accordionData="accordionItems"></TiptapEditor>
</div>
</template>
<script>
export default {};
<script setup>
const props = defineProps({
listConflicts: {
type: Array,
default: () => [],
},
});
const accordionItems = [
{
id: 1,
title: "عنوان",
content: "متن",
isOpen: true,
children: [],
},
];
</script>
<style></style>

View File

@ -1,11 +1,26 @@
<template>
<h1>RuleEdit</h1>
<TiptapEditor></TiptapEditor>
<div>
<HeaderTools></HeaderTools>
<TiptapEditor :accordionData="accordionItems"></TiptapEditor>
</div>
</template>
<script>
export default {};
<script setup>
const props = defineProps({
listConflicts: {
type: Array,
default: () => [],
},
});
const accordionItems = [
{
id: 1,
title: "عنوان",
content: "متن",
isOpen: true,
children: [],
},
];
</script>
<style></style>

View File

@ -0,0 +1,62 @@
<template>
<div ref="chartRef" class="chart"></div>
</template>
<script setup>
import * as echarts from 'echarts'
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
const props = defineProps({
option: {
type: Object,
required: true
},
autoresize: {
type: Boolean,
default: true
}
})
const chartRef = ref(null)
let chartInstance = null
const initChart = () => {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
chartInstance.setOption(props.option)
}
const resizeChart = () => {
chartInstance?.resize()
}
onMounted(() => {
initChart()
if (props.autoresize) {
window.addEventListener('resize', resizeChart)
}
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeChart)
chartInstance?.dispose()
})
/* وقتی option از بیرون عوض شد */
watch(
() => props.option,
(newOption) => {
if (chartInstance && newOption) {
chartInstance.setOption(newOption, true)
}
},
{ deep: true }
)
</script>
<style scoped>
.chart {
width: 100%;
height: 400px;
}
</style>

View File

@ -0,0 +1,906 @@
<template>
<div>
<div class="mt-3 text__15 text__green" v-if="description">
<span>{{ description }} </span>
</div>
<!-- tab/grouped mode -->
<template :key="renderMain" v-if="isArrayItems()">
<!-- start:horizontal mode -->
<template v-if="displayMode == 'horizontal'">
<div class="horizontal">
<!-- Tabs -->
<ul class="flex bg-gray-100 rounded-t-md overflow-hidden h-12">
<li
v-for="(groupItem, index) in localFormElements"
:key="'groupItem' + index"
@click="setTab(index)"
class="cursor-pointer mt-2"
>
<a
class="px-4 py-2 block relative"
:class="
currentTab === index
? 'bg-white text-gray-900 rounded-t-md'
: 'text-gray-600 hover:bg-gray-200'
"
>
{{ groupItem.title }}
</a>
</li>
</ul>
<!-- Tab content -->
<div
class="tab-content bg-white p-4 border border-t-0 border-gray-300 rounded-b-md"
>
<div
v-for="(groupItem, index) in localFormElements"
:key="'tabcontent' + index"
>
<div v-show="currentTab === index" class="px-3">
<form>
<div class="grid grid-cols-12 gap-4">
<div
v-for="(formElement, index1) in getGroupListElements(
groupItem.items,
)"
:key="formElement.key + index1"
:class="giveColClass('', formElement)"
>
<component
:is="
componentMap[
returnComponentNameDefault(formElement, groupItem)
]
"
:ref="`${refKeyBase}_${formElement.key}`"
:key="'element' + formElement.key + '_' + index1"
:formElement="
addValueToFormElement(formElement, groupItem)
"
:otherData="otherData"
:isReadOnly="readOnly"
:multipleMode="multipleMode"
:acceptType="acceptType"
:class="giveColClass('', formElement)"
:inputClass="giveColClass('input', formElement)"
:labelClass="'col-12'"
class="inside-entity align-items-center"
:classComponentName="classComponentName"
@updateComponentOption="
(ev) => updateComponentOption(ev, formElement)
"
@link-route-clicked="linkRouteClicked"
@take-value="
(ev) => takeValueChanged(ev, formElement, groupItem)
"
@on-upload-file="onUploadFile"
@action-affected-item="
(ev) => actionAffectedItem(ev, formElement)
"
@keydown="
formBuilderAction('formBuilderKeydown', $event)
"
/>
</div>
</div>
<!-- Submit Button -->
<div
v-if="isHorizontalButtonSubmit"
class="mt-4 flex justify-end"
>
<button
v-if="
isShowButtonAccordion(groupItem) &&
!isReadOnlyGroup(groupItem)
"
type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
@click.prevent="saveGroupProperty(groupItem)"
>
{{ groupItem.submit_label ?? "ثبت" }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<!-- start:vertical mode -->
<div
class="flex flex-col md:flex-row"
v-else-if="displayMode == 'vertical'"
>
<!-- Sidebar -->
<div :class="listClass ?? 'md:w-1/4 w-1/3'">
<div
class="flex flex-col space-y-2"
role="tablist"
aria-orientation="vertical"
>
<button
v-for="(groupItem, index) in localFormElements"
:key="'groupItem' + index"
@click="setTab(index)"
class="px-4 py-2 text-left rounded-lg transition-colors duration-200"
:class="
currentTab == index
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
"
role="tab"
:aria-selected="currentTab == index"
>
{{ groupItem.title }}
</button>
</div>
</div>
<!-- Content -->
<div :class="mainClass ?? 'md:w-3/4 w-2/3'">
<div class="tab-content">
<div
class="tab-pane transition-all duration-300"
:class="currentTab == index ? 'block' : 'hidden'"
v-for="(groupItem, index) in localFormElements"
:key="'tab-pane' + index"
role="tabpanel"
>
<div class="ml-5 relative">
<div class="container-fluid">
<div class="grid grid-cols-12 gap-4">
<div
v-for="(formElement, index1) in getGroupListElements(
groupItem?.items,
)"
:key="index1"
:class="giveColClass('', formElement)"
>
<component
:is="
componentMap[
returnComponentNameDefault(formElement, groupItem)
]
"
:ref="`${refKeyBase}_${formElement.key}`"
:key="'element' + formElement.key + '_' + index1"
:formElement="
addValueToFormElement(formElement, groupItem)
"
:otherData="otherData"
:chartComponentName="formElement.chartComponentName"
:isReadOnly="readOnly"
:multipleMode="multipleMode"
:acceptType="acceptType"
:class="
giveColClass('', formElement) +
' inside-entity align-items-center'
"
:inputClass="giveColClass('input', formElement)"
:labelClass="'col-12'"
:classComponentName="classComponentName"
@treemap-node-click="
formBuilderAction('treemap-node-click', $event)
"
@updateComponentOption="
(ev) => updateComponentOption(ev, formElement)
"
@link-route-clicked="linkRouteClicked"
@take-value="
(ev) => takeValueChanged(ev, formElement, groupItem)
"
@on-upload-file="onUploadFile"
@action-affected-item="
(ev) => actionAffectedItem(ev, formElement)
"
@keydown="
formBuilderAction('formBuilderKeydown', $event)
"
:widthChart="'100%'"
:heightChart="'64dvh'"
:isOpenModal="false"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- accordion & accordion-show -->
<template
v-else-if="
displayMode == 'accordion' || displayMode == 'accordion-show'
"
>
<UAccordion
:items="accordionItems"
:type="displayMode === 'accordion-show' ? 'multiple' : 'single'"
:collapsible="displayMode !== 'accordion-show'"
:default-value="
displayMode === 'accordion-show'
? accordionItems.map((_, i) => String(i))
: []
"
:unmount-on-hide="false"
:ui="{
root: 'w-full space-y-2',
item: 'rounded-md border border-gray-200 dark:border-gray-700',
header: 'flex bg-gray-100 dark:bg-dark-primary-700 rounded-md',
trigger:
'group flex-1 items-center justify-between gap-2 py-3 px-4 font-medium text-start focus-visible:outline-primary cursor-pointer',
content:
'overflow-hidden data-[state=open]:animate-[accordion-down_200ms_ease-out] data-[state=closed]:animate-[accordion-up_200ms_ease-out]',
body: 'p-0',
label: 'text-base font-medium text-gray-900 dark:text-white',
trailingIcon:
'ms-auto size-5 text-gray-500 group-data-[state=open]:rotate-180 transition-transform',
}"
>
<template #content="{ item, index }">
<form @submit.prevent="saveGroupProperty(item.data)">
<div class="p-4">
<div v-if="item.data.is_array" class="mb-4">
<TableComponent
:key="`table-${index}`"
height="14em"
:tableColumns="item.data.table_columns"
:tagActions="item.data.tag_actions"
:items="localFormData[item.data.key]"
:itemKey="item.data.key"
:formElements="item.data.items"
:isReadOnly="isReadOnlyGroup(item.data)"
:showActions="!isReadOnlyGroup(item.data)"
@updateTableComponent="updateTableComponent"
@add-table-item="addTableItem(item.data)"
@edit-table-item="editTableItem($event, item.data)"
@action-table-item="actionTableItem($event, item.data)"
@delete-table-item="deleteTableItem($event, item.data)"
/>
</div>
<div v-else class="grid grid-cols-12 gap-4 mb-4">
<div
v-for="(formElement, idx) in getGroupListElements(
item.data.items,
)"
:key="`${formElement.key}-${idx}`"
:class="giveColClass('', formElement)"
>
<component
:is="
componentMap[
returnComponentNameDefault(formElement, item.data)
]
"
:ref="`${refKeyBase}_${formElement.key}`"
:formElement="
addValueToFormElement(formElement, item.data)
"
:otherData="otherData"
:isReadOnly="isReadOnlyGroup(item.data, formElement)"
:multipleMode="multipleMode"
:acceptType="acceptType"
:class="giveColClass('', formElement)"
:inputClass="giveColClass('input', formElement)"
classComponentName="inside-entity"
@updateComponentOption="
(ev) => updateComponentOption(ev, formElement)
"
@link-route-clicked="linkRouteClicked"
@take-value="
(ev) => takeValueChanged(ev, formElement, item.data)
"
@on-upload-file="onUploadFile"
@action-affected-item="
(ev) => actionAffectedItem(ev, formElement)
"
@openModalTags="openModalTags"
@keydown="formBuilderAction('formBuilderKeydown', $event)"
/>
</div>
</div>
<div
v-if="
isShowButtonAccordion(item.data) &&
!isReadOnlyGroup(item.data)
"
class="flex justify-end"
>
<UButton
type="submit"
variant="solid"
color="primary"
size="md"
class="px-4 cursor-pointer"
@click.prevent="saveGroupProperty(item.data)"
>
{{ item.data.submit_label || "ثبت" }}
</UButton>
</div>
</div>
</form>
</template>
</UAccordion>
</template>
<!-- default mode -->
<template v-else-if="displayMode == 'default'">
<div class="container-fluid">
<div class="row grid grid-cols-12 gap-4">
<div
v-for="(formElement, index) in getGroupListElements(
localFormElements,
)"
:key="formElement.key + index"
:class="giveColClass('', formElement)"
>
<component
:is="
componentMap[
returnComponentNameDefault(formElement, groupItem)
]
"
:ref="`${refKeyBase}_${formElement.key}`"
:key="index + formElement.key + render"
:formElement="
addValueToFormElement(formElement, localFormElements)
"
:otherData="otherData"
:isReadOnly="readOnly"
:multipleMode="multipleMode"
:acceptType="acceptType"
:class="giveColClass(formElement.type, formElement)"
:inputClass="giveColClass('input', formElement)"
:labelClass="giveColClass('label', formElement)"
class="inside-entity align-items-center"
:classComponentName="classComponentName"
@updateComponentOption="
(ev) => updateComponentOption(ev, formElement)
"
@on-action-handler="onActionHandler"
@link-route-clicked="linkRouteClicked"
@take-value="
(ev) => takeValueChanged(ev, formElement, localFormElements)
"
@on-upload-file="onUploadFile"
@action-affected-item="
(ev) => actionAffectedItem(ev, formElement)
"
@take-value-validate="
formBuilderAction('take-value-validate', $event)
"
@keydown="formBuilderAction('formBuilderKeydown', $event)"
/>
</div>
</div>
</div>
</template>
</template>
</div>
</template>
<script setup>
import {
ref,
reactive,
computed,
watch,
onMounted,
onBeforeUnmount,
nextTick,
} from "vue";
import { cloneDeep } from "lodash";
// Async Components (same as before, but using defineAsyncComponent explicitly)
// --- جایگزینی بخش import و تعریف componentMap ---
import FormInputComponent from "@/components/lazy-load/form-builder/FormInputComponent.vue";
import FormTextareaComponent from "@/components/lazy-load/form-builder/FormTextareaComponent.vue";
import FormSelectComponent from "@/components/lazy-load/form-builder/FormSelectComponent.vue";
import FormDateComponent from "@/components/lazy-load/form-builder/FormDateComponent.vue";
import FormUploadFilesComponent from "@/components/lazy-load/form-builder/FormUploadFilesComponent.vue";
// Transform localFormElements into AccordionItem[]
const accordionItems = computed(() => {
return localFormElements.value.map((groupItem, index) => ({
label: groupItem.title,
// Optional: you can add `value` if needed for controlled mode
// value: String(index),
// Pass original data for slots
data: groupItem,
}));
});
// سایر کامپوننتها را هم به همین ترتیب اضافه کنید:
// import FormSelectComponent from '...';
// import FormLabelComponentReadOnly from '...';
const componentMap = {
FormInputComponent,
FormTextareaComponent,
FormSelectComponent,
FormDateComponent,
FormUploadFilesComponent,
// ... سایر کامپوننتهایی که استفاده میکنید
};
const props = defineProps({
refKeyBase: {
type: String,
default: "formbuilder",
},
displayMode: {
type: String,
default: "horizontal",
},
readOnly: {
type: Boolean,
default: false,
},
dataChart: {
type: Array,
default: () => [],
},
chartComponentName: {
type: String,
default: "",
},
formElements: {
type: Array,
default: () => [],
},
description: {
type: String,
default: "",
},
formData: {
type: Object,
default: () => ({}),
},
otherData: {
type: [Object, Array],
default: null,
},
previewMode: {
type: Boolean,
default: false,
},
classComponentName: {
type: String,
default: "",
},
multipleMode: {
type: Boolean,
default: true,
},
isHorizontalButtonSubmit: {
type: Boolean,
default: false,
},
acceptType: {
type: String,
default: "",
},
listClass: {
type: String,
default: "col-md-12",
},
mainClass: {
type: String,
default: "col-md-9",
},
});
// Emits (explicitly defined)
const emit = defineEmits(["form-builder-action"]);
function formBuilderAction(action, payload) {
emit("form-builder-action", {
action: action,
payload: payload,
});
}
// === Reactive State ===
const renderMain = ref(100);
const tableRender = ref(0);
const currentTab = ref(0);
const prevActiveTabIndex = ref(undefined);
const localFormElements = ref([]);
const localFormData = ref({});
const localFormDataChanged = ref({});
const activeRequest = ref([]);
let this_vm = getCurrentInstance();
// === Computed ===
const buttonText = computed(() => {
return localFormData.value?.id || localFormData.value?.guid
? "بروزرسانی"
: "افزودن";
});
// === Methods ===
const foundItemFormData = (key, index) => {
let item;
Object.keys(localFormData.value).forEach((nameItem) => {
if (nameItem === key) {
const arrayItemFound = localFormData.value[nameItem];
item = arrayItemFound?.[index];
}
});
return item;
};
const updateTableComponent = (event) => {
const { key, items } = event;
localFormData.value[key] = items;
localFormDataChanged.value[key] = items;
};
const isShowButtonAccordion = (item) => {
if (item.submit_label === "") return false;
return !props.readOnly;
};
const isReadOnlyGroup = (groupItem, item = undefined) => {
if (props.readOnly) return true;
let res = false;
if ("has_permission" in groupItem) res = !groupItem.has_permission;
if (item && "readOnly" in item && !res) res = item.readOnly;
return res;
};
const saveGroupProperty = (groupItem) => {
let changed_data = localFormDataChanged.value;
if (localFormDataChanged.value[groupItem.key]) {
changed_data = localFormDataChanged.value[groupItem.key];
}
let data = {};
let data_form = {};
if (!groupItem.type_data || groupItem.type_data === "normal") {
groupItem.items?.forEach((item) => {
if (changed_data[item.key]) data[item.key] = changed_data[item.key];
});
data_form = data;
} else {
data_form[groupItem.key] = changed_data;
}
formBuilderAction("saveFormGroup", data_form);
};
const onActionHandler = ({ action, key, value }) => {
const target = action.target;
updateValueFormData(target, value);
};
const isArrayItems = () => {
return Array.isArray(localFormElements.value);
};
const listenEventBus = ({ value, affectedTo }) => {
const refkey = `${props.refKeyBase}_${affectedTo.key}`;
const refs = this_vm.refs || {};
if (refs[refkey]) {
try {
const method =
affectedTo.action === "ufInitTextValue"
? (v) => refs[refkey][0]?.ufInitTextValue(v, true)
: (v) => refs[refkey][0]?.[affectedTo.action]?.(v);
method?.(value);
} catch (err) {
// silent
}
} else {
localFormData.value[affectedTo.key] = value;
localFormDataChanged.value[affectedTo.key] = value;
}
};
const setTab = (index) => {
if (prevActiveTabIndex.value !== undefined) {
localFormElements.value[prevActiveTabIndex.value].active = false;
}
currentTab.value = index;
prevActiveTabIndex.value = index;
formBuilderAction("setTab", index);
};
const getGroupListElements = (items) => {
if (!items) return [];
return items.filter((el) => !("ishide" in el) || el.ishide !== 1);
};
const findItemSchema = (key, items = undefined) => {
items = items || localFormElements.value;
for (let i = 0; i < items.length; i++) {
if (items[i]?.key === key) return items[i];
if (items[i].items) {
const found = findItemSchema(key, items[i].items);
if (found) return found;
}
}
return undefined;
};
const getSourceData = (itemData, key) => {
let elMom = itemData;
const elKeys = key.split("__");
for (let i = 0; i < elKeys.length - 1; i++) {
if (elMom?.[elKeys[i]]) {
elMom = elMom[elKeys[i]];
}
}
const finalKey = elKeys[elKeys.length - 1];
// console.log('getSourceData ', finalKey, elMom?.[finalKey], elMom);
return elMom?.[finalKey] ?? null;
};
const addValueToFormElement = (formElement, innerGroupItem) => {
const cloned = { ...formElement };
cloned["componentName"] = returnComponentNameDefault(cloned, innerGroupItem);
if (formElement.key) {
let formData = localFormData.value;
if (innerGroupItem && innerGroupItem?.type_data === "object") {
formData = localFormData.value[innerGroupItem.key] ?? {};
} else if (formElement.key == "file") {
formElement.key = "subtitle";
}
const value = getSourceData(formData, formElement.key);
cloned["value"] = value;
}
// console.log('addValueToFormElement ', formElement.key, cloned.value, cloned);
return cloned;
};
const returnComponentNameDefault = (item, groupItem) => {
const type = item.type;
// console.log("type ==> ", type);
const _readOnly = props.readOnly
? true
: groupItem
? isReadOnlyGroup(groupItem, item)
: false;
// console.log("_readOnly ==> ", _readOnly);
if (_readOnly) {
if (type === "textarea") return "FormTextareaComponent";
else if (type === "htmleditor") return "HtmlEditor";
else if (type === "tinyeditor") return "MyTinyMce";
else return "FormLabelComponentReadOnly";
}
switch (type) {
case "textarea":
return "FormTextareaComponent";
case "select":
return "FormSelectComponent";
case "string":
case "number":
case "float":
return "FormInputComponent";
case "label":
return "FormLabelComponentReadOnly";
case "tags":
return "tagsComponent";
case "selectTags":
return "SelectTagsComponent";
case "htmleditor":
return "HtmlEditor";
case "tinyeditor":
return "MyTinyMce";
case "date":
return "FormDateComponent";
case "range_date":
return "RangeDateComponent";
case "checkbox":
case "radio":
return "CheckboxComponent";
case "label_button":
return "LabelButtonComponent";
case "upload":
return "FormUploadFilesComponent";
case "multiFormSelectComponent":
return "MultiFormSelectComponent";
case "chart_content":
return "ChartContent";
default:
return "FormInputComponent";
}
};
const actionAffectedItem = ({ action, key, value }, schemaItem) => {
// console.log("actionAffectedItem ", action, key, value);
formBuilderAction("form-action-affected", { action, key, value, schemaItem });
};
const onUploadFile = (fileUploadData) => {
formBuilderAction("uploadFile", fileUploadData);
};
const takeValueObjectChanged = (value, formElement, innerGroupItem) => {
if (!innerGroupItem || innerGroupItem?.type_data !== "object") return false;
let root_object =
getSourceData(localFormData.value, innerGroupItem.key) ?? {};
root_object[formElement.key] = value;
localFormData.value[innerGroupItem.key] = root_object;
localFormDataChanged.value[innerGroupItem.key] = root_object;
return true;
};
const takeValueChanged = (value, schemaItem, innerGroupItem = null) => {
const key = schemaItem.key;
if (!takeValueObjectChanged(value, schemaItem, innerGroupItem)) {
if (typeof value === "string") value = value.trim() ?? null;
localFormData.value[key] = value;
localFormDataChanged.value[key] = value;
}
formBuilderAction("changeFormValues", { ...localFormDataChanged.value });
formBuilderAction("changeFormValueOne", { key, value });
};
const callItemMethod = (key, method, val) => {
const refkey = `${props.refKeyBase}_${key}`;
let refs = this_vm.refs || {};
if (refs[refkey]?.[0]?.[method]) {
refs[refkey][0][method](val);
}
};
const updateValueFormData = (key, value) => {
if (!value) return;
if (typeof value === "string") value = value.trim();
const refkey = `${props.refKeyBase}_${key}`;
let refs = this_vm.refs || {};
if (refs[refkey]?.initTextValue) {
refs[refkey].initTextValue(value);
}
localFormData.value[key] = value;
localFormDataChanged.value[key] = value;
formBuilderAction("changeFormValues", { ...localFormDataChanged.value });
formBuilderAction("changeFormValueOne", { key, value: value });
};
const giveColClass = (type, item) => {
// اگر کلاس دستی داده شده باشد استفاده میکنیم
// if (item.classes) {
// if (type === "") return item.classes;
// if (type === "label") return "col-auto";
// if (["input", "string", "textarea"].includes(type)) {
// return item.inputClass || item.classes;
// }
// }
// بررسی colSpan
const span = item.colSpan ?? 12; // پیشفرض کل عرض
const validSpan = Math.min(Math.max(span, 1), 12); // بین 1 تا 12
return `col-span-${validSpan}`;
};
const linkRouteClicked = (item) => {
if (!item.link_route) return;
const id_route = localFormData.value[item.link_route.id] ?? "";
const key = item.link_route.key;
const routeData = router.resolve({
name: item.link_route.name,
params: { id: id_route, key },
query: {},
});
window.open(routeData.href, "_blank");
};
// Table-specific handlers
const deleteTableItem = (event, schema) => {
const item = foundItemFormData(schema.key, event);
formBuilderAction("delete-table-item", {
schema,
item,
index: event,
});
};
const editTableItem = (event, schema) => {
const item = foundItemFormData(schema.key, event);
formBuilderAction("edit-table-item", {
schema,
item,
index: event,
});
};
const duplicateTableItem = (event, schema) => {
const item = foundItemFormData(schema.key, event);
formBuilderAction("duplicate-table-item", {
schema,
item,
index: event,
});
};
const actionTableItem = (event, schema) => {
emit(event.action, {
item: event.data.item,
index: event.data.index,
schema,
});
};
const addTableItem = (schema) => {
formBuilderAction("add-table-item", schema);
};
// Open modal for tags
const openModalTags = (item) => {
formBuilderAction("edit-property-tags", item);
};
// === Setup: Lifecycle & Watchers ===
import { getCurrentInstance } from "vue";
import { useNuxtApp } from "#app";
import { useRouter } from "vue-router";
const { $eventBus } = useNuxtApp();
const router = useRouter();
onMounted(() => {
localFormElements.value = props.formElements
? cloneDeep(props.formElements)
: [];
localFormData.value = props.formData ? cloneDeep(props.formData) : {};
renderMain.value++;
setTab(0);
// Event bus listener
if ($eventBus) {
$eventBus.on("catch-form-builder-event", listenEventBus);
}
});
onBeforeUnmount(() => {
if ($eventBus) {
$eventBus.off("catch-form-builder-event", listenEventBus);
}
});
// Watchers (deep + immediate)
watch(
() => props.formElements,
(newValue) => {
localFormElements.value = newValue ? cloneDeep(newValue) : [];
},
{ deep: true, immediate: true },
);
watch(
() => props.formData,
(newValue) => {
localFormData.value = newValue ? cloneDeep(newValue) : {};
},
{ deep: true, immediate: true },
);
// Expose methods if needed for parent refs (optional, if used externally)
defineExpose({
callItemMethod,
updateValueFormData,
setTab,
takeValueChanged,
});
</script>
<style lang="scss"></style>

View File

@ -0,0 +1,874 @@
<template>
<div class="my-content">
<template v-if="localViewMode == 'list'">
<div class="flex flex-wrap -mx-2">
<div
class="w-full px-2 main-content firefox-scrollbar"
:style="{ height: props.mainSchema.height }"
>
<template v-if="props.mainSchema.items.length">
<div
class="mb-3 border-b main-content-item p-3"
v-for="(itemData, i) in props.mainSchema.items"
:key="i"
@click="changeCurrent(itemData)"
@contextmenu.prevent="onRightClick(itemData, $event)"
>
<template v-if="itemData.inner_hits">
<div
class="flex flex-wrap -mx-2"
v-for="(collapseItem, indexCollapse) in props.mainSchema
.schemaItems.collapse_items?.items || []"
:key="indexCollapse"
>
<template v-if="collapseItem.array_key">
<div
class="w-full px-2"
v-for="(subItemData, s) in getArrayData(
itemData,
collapseItem,
)"
:key="s"
>
<lineContent
:lineSchema="collapseItem"
:itemData="subItemData"
:arrayItemData="props.mainSchema.items"
:textSearch="props.mainSchema.textSearch"
@openModalHandler="openModalHandler"
@click-item="onMyContentAction('click-item', $event)"
>
</lineContent>
</div>
</template>
<template v-else>
<lineContent
:lineSchema="collapseItem"
:itemData="itemData"
:arrayItemData="props.mainSchema.items"
:textSearch="props.mainSchema.textSearch"
@openModalHandler="openModalHandler"
@click-item="onMyContentAction('click-item', $event)"
>
</lineContent>
</template>
</div>
</template>
<template
v-else-if="
props.mainSchema.schemaItems &&
props.mainSchema.schemaItems.items
"
>
<div
class="flex flex-wrap -mx-2"
v-for="(lineSchema, index) in props.mainSchema.schemaItems
?.items"
:key="index"
>
<lineContent
:lineSchema="lineSchema"
:itemData="itemData"
:arrayItemData="props.mainSchema.items"
:textSearch="props.mainSchema.textSearch"
@openModalHandler="openModalHandler"
@click-item="onMyContentAction('click-item', $event)"
>
</lineContent>
</div>
</template>
<template v-if="props.mainSchema.isSearchingState">
<div class="flex items-center">
<label
class="whitespace-nowrap ml-1"
style="color: gray; font-family: sahel-semi-bold"
>
پاسخ را همراه امتیاز ذخیره کنید :
</label>
<NuxtRating
class="star-rating"
:read-only="false"
:rating-value="itemData.rate_value ?? 0"
border-color="#db8403"
active-color="#ffa41c"
inactive-color="#fff"
:rating-step="0.5"
:rounded-corners="true"
:border-width="5"
:rating-size="20"
@rating-selected="
saveRating(
$event,
i,
itemData,
props.mainSchema.schemaItems,
)
"
@rating-hovered="(event) => (rating.value = event)"
/>
</div>
</template>
<div
class="search-item__actions"
v-if="props.mainSchema.schemaItems.actions?.length"
>
<span class="tavasi tavasi-more-vert"></span>
<template
v-for="(schema, indexIcon) in props.mainSchema.schemaItems
.actions"
:key="'action' + indexIcon"
>
<div class="search-item__actions flex gap-1 mt-2">
<UButton
v-for="(schema, index) in props.mainSchema.schemaItems
.actions"
:key="'action-' + index"
:title="schema.title"
variant="ghost"
size="xs"
:ui="{ icon: { base: 'w-4 h-4' }, padding: 'p-1.5' }"
@click.stop="handleActionClick(itemData, schema)"
>
<UIcon
v-if="schema.key === 'tbookmark'"
:name="
itemData._source.tbookmark == 1
? schema.toggle_icons?.icon1
: schema.toggle_icons?.icon2
"
class="w-4 h-4 text-dark-primary-800"
/>
<UIcon
v-else-if="schema.icon"
:name="schema.icon"
class="w-4 h-4 text-dark-primary-800"
/>
<span v-else class="text-xs">{{ schema.title }}</span>
</UButton>
</div>
</template>
</div>
</div>
</template>
<template v-else>
<table-no-data></table-no-data>
</template>
</div>
</div>
<div class="">
<myPagination
v-model:paginationInfo="localPagination"
:show-total-records="true"
:show-page-selection="true"
@pageChanged="fetchData"
@limitChanged="fetchData"
/>
</div>
</template>
<!-- viewMode = 'table' -->
<template v-if="localViewMode == 'table'">
<div class="flex flex-wrap -mx-2">
<div class="w-full px-0">
<MyTable
:table-columns="props.mainSchema.tableColumns"
:action-buttons="props.mainSchema.tableActions"
:raw-data="props.mainSchema.items"
:cell-menu-items="props.mainSchema.menuItems"
:show-search="false"
:tableBodyMaxHeight="props.mainSchema.height"
:pagination="localPagination"
@my-table-action="myTableAction"
/>
<myPagination
v-model:paginationInfo="localPagination"
:show-total-records="true"
:show-page-selection="true"
@pageChanged="fetchData"
@limitChanged="fetchData"
/>
</div>
</div>
</template>
<!-- viewMode = 'card' -->
<template v-if="localViewMode == 'card'">
<div class="flex flex-wrap -mx-2">
<div class="w-full px-2 main-content firefox-scrollbar">
<div
class="flex flex-wrap -mx-2"
v-if="props.mainSchema.items.length"
>
<div
v-for="(itemData, i) in props.mainSchema.items"
:key="i"
class="mb-4 w-full sm:w-1/2 md:w-1/3 lg:w-1/4 xl:w-1/5 px-2"
>
<div
class="main-content-item-card p-4 rounded-lg shadow-md border border-gray-200 bg-white flex flex-col h-full"
>
<div v-if="!itemData?._source" class="text-red-500 italic">
داده نامعتبر
</div>
<template v-else>
<div
v-if="props.mainSchema.schemaItems?.items?.length"
class="flex flex-col gap-2"
>
<template
v-for="(lineSchema, index) in props.mainSchema.schemaItems
.items"
:key="index"
>
<lineContent
:lineSchema="lineSchema"
:itemData="itemData"
:arrayItemData="props.mainSchema.items"
:textSearch="props.mainSchema.textSearch"
@openModalHandler="openModalHandler"
@click-item="onMyContentAction('click-item', $event)"
/>
</template>
</div>
<div
v-else-if="itemData._source.title"
class="font-semibold text-gray-800"
>
{{ itemData._source.title }}
</div>
<div v-else class="text-gray-500 text-sm italic">
بدون عنوان
</div>
</template>
<div
v-if="props.mainSchema.schemaItems.actions?.length"
class="mt-auto pt-3 border-t border-gray-100 flex gap-2"
>
<UButton
v-for="(schema, idx) in props.mainSchema.schemaItems
.actions"
:key="'action-' + idx"
:title="schema.title"
variant="ghost"
size="xs"
:ui="{ icon: { base: 'w-4 h-4' }, padding: 'p-1.5' }"
@click.stop="handleActionClick(itemData, schema)"
>
<UIcon
v-if="schema.key === 'tbookmark'"
:name="
itemData._source.tbookmark == 1
? schema.toggle_icons?.icon1
: schema.toggle_icons?.icon2
"
class="w-4 h-4 text-gray-800"
/>
<UIcon
v-else-if="schema.icon"
:name="schema.icon"
class="w-4 h-4 text-gray-800"
/>
<span v-else class="text-xs">{{ schema.title }}</span>
</UButton>
</div>
</div>
</div>
</div>
<template v-else>
<table-no-data />
</template>
</div>
</div>
</template>
<!-- viewMode = 'three-column-card' -->
<template v-if="localViewMode == 'three-column-card'">
<div class="flex flex-wrap -mx-2">
<div class="w-full px-2 main-content firefox-scrollbar">
<div
class="flex flex-wrap -mx-2"
v-if="props.mainSchema.items.length"
>
<div
class="mb-3 main-content-item p-3 w-full sm:w-1/3 px-2"
v-for="(itemData, i) in props.mainSchema.items"
:key="i"
>
<div class="flex justify-center"></div>
<template v-if="itemData.inner_hits">
<div
class="flex flex-wrap -mx-2"
v-for="(collapseItem, indexCollapse) in props.mainSchema
.schemaItems.collapse_items?.items || []"
:key="indexCollapse"
>
<template v-if="collapseItem.array_key">
<div
class="w-1/3 px-2"
v-for="(subItemData, s) in getArrayData(
itemData,
collapseItem,
)"
:key="s"
>
<lineContent
:lineSchema="collapseItem"
:itemData="subItemData"
:arrayItemData="props.mainSchema.items"
:textSearch="props.mainSchema.textSearch"
>
</lineContent>
</div>
</template>
<template v-else>
<lineContent
:lineSchema="collapseItem"
:itemData="itemData"
:arrayItemData="props.mainSchema.items"
:textSearch="props.mainSchema.textSearch"
>
</lineContent>
</template>
</div>
</template>
<template v-else>
<div
v-if="
props.mainSchema.schemaItems &&
props.mainSchema.schemaItems.items
"
class="flex flex-wrap -mx-2"
v-for="(lineSchema, index) in props.mainSchema.schemaItems
?.items || []"
:key="index"
>
<lineContent
:lineSchema="lineSchema"
:itemData="itemData"
:arrayItemData="props.mainSchema.items"
:textSearch="props.mainSchema.textSearch"
@openModalHandler="openModalHandler"
@click-item="onMyContentAction('click-item', $event)"
>
</lineContent>
</div>
</template>
<div
class="search-item__actions"
v-if="props.mainSchema.schemaItems.actions"
>
<span class="tavasi tavasi-more-vert"></span>
</div>
</div>
</div>
<template v-else>
<table-no-data></table-no-data>
</template>
</div>
</div>
</template>
<!-- viewMode = 'two-column-card' -->
<template v-if="localViewMode == 'two-column-card'">
<div class="flex flex-wrap -mx-2">
<div class="w-full sm:w-1/2 px-2 main-content firefox-scrollbar">
<template v-if="props.mainSchema.items.length">
<div
class="mb-3 border-b main-content-item p-3"
v-for="(itemData, i) in props.mainSchema.items"
:key="i"
>
<template v-if="itemData.inner_hits">
<div
class="flex flex-wrap -mx-2"
v-for="(collapseItem, indexCollapse) in props.mainSchema
.schemaItems.collapse_items?.items || []"
:key="indexCollapse"
>
<template v-if="collapseItem.array_key">
<div
class="w-full px-2"
v-for="(subItemData, s) in getArrayData(
itemData,
collapseItem,
)"
:key="s"
>
<lineContent
:lineSchema="collapseItem"
:itemData="subItemData"
:arrayItemData="props.mainSchema.items"
:textSearch="props.mainSchema.textSearch"
>
</lineContent>
</div>
</template>
<template v-else>
<lineContent
:lineSchema="collapseItem"
:itemData="itemData"
:arrayItemData="props.mainSchema.items"
:textSearch="props.mainSchema.textSearch"
>
</lineContent>
</template>
</div>
</template>
<template v-else>
<div
v-if="
props.mainSchema.schemaItems &&
props.mainSchema.schemaItems.items
"
class="flex flex-wrap -mx-2"
v-for="(lineSchema, index) in props.mainSchema.schemaItems
?.items || []"
:key="index"
>
<lineContent
:lineSchema="lineSchema"
:itemData="itemData"
:arrayItemData="props.mainSchema.items"
:textSearch="props.mainSchema.textSearch"
@openModalHandler="openModalHandler"
@click-item="onMyContentAction('click-item', $event)"
>
</lineContent>
</div>
</template>
<div
class="search-item__actions"
v-if="props.mainSchema.schemaItems.actions"
>
<span class="tavasi tavasi-more-vert"></span>
</div>
</div>
</template>
<template v-else>
<table-no-data></table-no-data>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onBeforeMount, onMounted, onUnmounted } from "vue";
import { useNuxtApp, useRoute, useRouter } from "#app";
import lineContent from "@/components/lazy-load/global/lineContent.vue";
import MyTable from "@/components/lazy-load/global/MyTable.vue";
import { watch } from "vue";
// --- متغیرهای نمونه (برای تست) ---
const columns = []; // حذف شد چون استفاده نمیشود
const buttons = []; // حذف شد
const data = []; // حذف شد
const menuItems = []; // حذف شد
const props = defineProps({
mainSchema: {
type: Object,
required: true,
default: () => ({}),
},
pagination: {
// اضافه شد
type: Object,
required: true,
},
});
const localPagination = ref({
// pages: 1000,
total: 0,
page: 1,
// offset: 0,
limit: 10,
});
watch(
() => props.mainSchema.pagination,
(newPagination) => {
if (newPagination) {
localPagination.value.total = newPagination.total ?? 0;
// page و limit معمولاً توسط fetchData ست شدن، ولی اگر Parent override کنه:
if (newPagination.page) localPagination.value.page = newPagination.page;
if (newPagination.limit)
localPagination.value.limit = newPagination.limit;
}
},
{ immediate: true, deep: true },
);
const fetchData = (newPagination) => {
// newPagination میتونه از myPagination بیاد
// مثلاً: { pageNumber: 3, limit: 20 }
const page = newPagination.pageNumber ?? localPagination.value.page;
const limit = newPagination.limit ?? localPagination.value.limit;
// state داخلی رو بهروز کن (صفحه و limit)
localPagination.value.page = page;
localPagination.value.limit = limit;
// فقط این دو مورد رو بفرست به Parent
onMyContentAction("pagination", { page, limit });
};
const myTableAction = ({ action, payload }) => {
// console.log("myTableAction :", action, payload);
onMyContentAction(action, payload);
};
const onMyContentAction = (action, payload) => {
emit("my-content-action", {
action: action,
payload: payload,
});
};
// --- Props و Emits ---
const localViewMode = ref(props.mainSchema.viewMode);
// فقط یک ایمیت واحد
const emit = defineEmits(["my-content-action"]);
// --- Composables ---
const route = useRoute();
const router = useRouter();
// --- Refs ---
const contextMenu = ref({ visible: false, x: 0, y: 0, node: null });
const rating = ref("");
const page = ref({ pages: 0, total: 0, page: 1, offset: 0, limit: 10 });
const reRender = ref(1);
const sorting = ref({ sortby: "created", sortorder: undefined });
// --- Methods ---
const contextMenuAction = (payload) =>
onMyContentAction("context-menu-action", payload);
const onRightClick = (node, event) => {
if (props.mainSchema.contextMenuItems?.length) {
contextMenu.value = {
x: event.clientX,
y: event.clientY,
visible: true,
node: node,
};
document.addEventListener("click", hideContextMenu);
}
};
const hideContextMenu = () => {
contextMenu.value.visible = false;
document.removeEventListener("click", hideContextMenu);
};
const saveRating = (rate_value, index, itemData, schemaItems) => {
let schema = undefined;
if (itemData?.inner_hits && schemaItems?.collapse_items?.items?.length)
schema = schemaItems.collapse_items.items[0];
else if (schemaItems?.items?.length) schema = schemaItems.items[0];
if (!schema) return;
const key_id = schema.link_id ?? "_id";
itemData.rate_value = rate_value;
props.mainSchema.items[index] = itemData;
const id_route = getSourceData(itemData, key_id);
onMyContentAction("rate-item", { id: id_route, rate: rate_value });
};
const openModalHandler = (item, isReadonly = true) =>
onMyContentAction("ModalHandler", { item, isReadonly });
const handlerActions = (event) => {
const { key } = event.rowAction;
if (key === "summary") {
onMyContentAction("show-summary", event.item);
} else if (key === "copy") {
copyToClipboard(
"",
urlResolver(event.item._id, event.rowAction.link_route, event.item),
);
} else if (key === "tbookmark") {
AddToFavorites(event.item, event.rowAction, event.index);
} else {
onMyContentAction("actionsHandler", { key, event });
}
};
const AddToFavorites = (item, icon, index) => {
onMyContentAction("myContent_addToFavorites", { item, icon, index });
};
const handlerActionsList = (item, key, icon) => {
if (key === "summary") {
onMyContentAction("show-summary", item);
} else if (key === "copy") {
copyToClipboard(
"",
urlResolver(
findvalueForKey(item, icon.link_route.id),
icon.link_route,
item,
),
);
} else if (key === "SubjectForm") {
onMyContentAction("SubjectForm", item);
} else if (key === "edit") {
onMyContentAction(icon.emit ? "edit" : "ModalHandler", {
item,
key,
icon,
isReadonly: !icon.emit,
});
} else if (key === "delete") {
onMyContentAction(icon.emit ? "delete" : "deleteResearch", item);
}
};
const urlResolver = (_id, route, item) => {
let query = {};
if (buildName?.() !== "majles")
query.searchtext = props.mainSchema.textSearch ?? undefined;
if (route?.query) {
if (typeof route.query === "string") {
const [query_key, query_value] = route.query.split("=");
if (query_key && query_value) query[query_key] = query_value;
} else {
for (const [key, value] of Object.entries(route.query)) {
query[key] = item[value] ?? item?._source[value];
}
}
}
const id_route = getSourceData(item, route?.id);
const id_route2 = route?.id2 ? getSourceData(item, route.id2) : undefined;
const key = getSourceData(item, route?.key_item) ?? route.key;
if (route.name === "navigation" || route.name === "navigationView")
query.ls = "list";
return router.resolve({
name: "navigationView",
params: { id: id_route, id2: id_route2, key },
query,
}).href;
};
const getSourceData = (itemData, key) => {
let sourceData = itemData;
key.split(".").forEach((k) => {
sourceData = sourceData?.[k] ?? sourceData?._source?.[k];
});
return sourceData;
};
const getArrayData = (itemData, collapseItem) => {
if (!collapseItem.array_key) return [itemData];
const sourceData = getSourceData(itemData, collapseItem.array_key);
return Array.isArray(sourceData) ? sourceData : [itemData];
};
const findvalueForKey = (item, keyName) =>
item[keyName] ?? item?._source[keyName];
const onLinkedTitleClick = ({ rowItem, tableColumn }) => {
let key = props.mainSchema.mainSchemaKey;
if (key === "qsection") key = "qasection";
else if (key === "rsection") key = "rgsection";
const title_value =
key === "qasection" || key === "rgsection"
? rowItem._source.qanon_title
: rowItem._source.title;
const valueId = findvalueForKey(rowItem, tableColumn.link_route?.id);
if (valueId) {
onMyContentAction("click-item", { id: valueId, title: title_value });
}
const href = urlResolver(valueId, tableColumn.link_route, rowItem);
const cloneList = props.mainSchema.items.map((item) => ({
_id: item._id,
_source: {
id: item._id,
title: item?._source?.title,
qanon_title: item?._source?.qanon_title,
qanon_id: item?._source?.qanon_id,
ref_key: key,
},
}));
localStorage.setItem("myList", JSON.stringify(cloneList));
localStorage.setItem("myItem", JSON.stringify(rowItem));
window.open(href, "_blank");
};
const pageLimitChanged = (paging) => {
resetPagination();
page.value.limit = paging.limit;
onMyContentAction("changePage", { ...page.value });
};
const pageChanged = (paging) => {
const p = paging.pageNumber - 1;
page.value.offset = p * paging.limit;
page.value.limit = paging.limit;
page.value.page = paging.pageNumber;
onMyContentAction("changePage", { ...page.value });
};
const sortChanged = (sortingInfo) => {
page.value.page = 0;
page.value.offset = 0;
sorting.value = sortingInfo;
onMyContentAction("changePage", { ...sorting.value });
};
const resetPagination = () => {
page.value = { pages: 0, total: 0, page: 1, offset: 0, limit: 10 };
};
const changeCurrent = (item) => {
onMyContentAction("changeCurrent", item);
};
const handleActionClick = (itemData, schema) => {
handlerActionsList(itemData, schema.key, schema);
};
// --- Lifecycle ---
onBeforeMount(() => {
if (props.mainSchema.items && route.params?.key === "qasection") {
props.mainSchema.items.forEach((item) => {
if (!item._source.qanon_etebar?.trim())
item._source.qanon_etebar = "معتبر";
});
}
});
onMounted(() => {
page.value = props.mainSchema.pagination || {
pages: 0,
total: 0,
page: 1,
offset: 0,
limit: 10,
};
fetchData({ pageNumber: 1, limit: 10 });
});
onUnmounted(() => {
document.removeEventListener("click", hideContextMenu);
});
</script>
<style lang="scss" scoped>
/* سبک‌ها بدون تغییر */
.main-content-item {
position: relative;
overflow: hidden;
&:hover,
&.active {
background-color: #e8fcff;
.search-item__actions {
width: auto;
transition: width 0.5s;
background: #fff;
border-radius: 0 0.5em 0.5em 0;
.tavasi-more-vert {
transition: all 0.2s;
display: none;
}
}
}
}
.main-content-item-card {
box-shadow: rgba(191, 191, 191, 0.24) 0px 0px 3px 1px;
border-radius: 10px;
position: relative;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
&:hover,
&.active {
background-color: var(--list-background-color);
.search-item__actions {
width: auto;
transition: width 0.5s;
background: #fff;
border-radius: 0 0.5em 0.5em 0;
.tavasi-more-vert {
transition: all 0.2s;
display: none;
}
}
}
}
.search-item__actions {
position: absolute;
left: 0;
width: 1.6em;
top: 1em;
transition: all 0.5s;
display: flex;
align-items: center;
.btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.175rem 0.35rem;
&:hover {
filter: brightness(0.7);
}
.icon-copy2 {
font-size: 0.8rem;
}
&.favorites {
color: var(--color-primary-color);
.icon-bookmark-1,
.icon-bookmark-2 {
height: 1.3em;
}
}
}
}
.main-content {
height: calc(100dvh - 15em);
overflow: auto;
}
@media (max-width: 575.98px) {
.main-content {
height: calc(100dvh - 17em);
}
}
.star-rating-text {
float: left;
margin-left: 10px;
}
</style>
<style lang="scss">
.my-content {
&.refine-main-filter-enabled {
.my-content-table {
.table-responsive {
height: calc(-22.5em + 100vh) !important;
}
}
}
}
</style>

View File

@ -0,0 +1,763 @@
<template>
<div class="">
<!-- Header: جستجو -->
<div class="mb-6 flex items-center gap-2" v-if="props.showSearch">
<UInput
v-model="searchQuery"
placeholder="...جستجو"
class="w-full max-w-xs bg-gray-100 dark:bg-dark-primary-800 border border-gray-300 dark:border-dark-primary-700 rounded px-3 py-2 text-dark-primary-800 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<UButton
v-if="searchQuery"
icon="heroicons:x-mark"
size="sm"
color="gray"
variant="ghost"
@click="searchQuery = ''"
class="px-2 py-1 rounded bg-gray-200 dark:bg-dark-primary-700 hover:bg-gray-300 dark:hover:bg-dark-primary-600 text-dark-primary-800 dark:text-gray-100 transition-colors"
/>
</div>
<!-- جدول / کارتها -->
<div
class="bg-white dark:bg-dark-primary-800 rounded-lg overflow-hidden shadow-md border border-gray-200 dark:border-dark-primary-700"
>
<!-- حالت دسکتاپ: جدول -->
<div class="hidden md:block">
<div
class="bg-white dark:bg-dark-primary-800 rounded-lg overflow-hidden shadow-md border border-gray-200 dark:border-dark-primary-700"
>
<table
class="w-full text-sm min-w-full"
style="table-layout: fixed; border-collapse: collapse"
>
<thead
class="bg-gray-50 dark:bg-dark-primary text-dark-primary-700 dark:text-gray-300"
style="display: table; width: 100%; table-layout: fixed"
>
<tr>
<th
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider whitespace-nowrap"
style="width: 3rem"
>
#
</th>
<th
v-for="col in tableColumns"
:key="col.key"
:style="{ width: col.width ? col.width : 'auto' }"
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider"
>
{{ col.title }}
</th>
<th
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider whitespace-nowrap"
style="width: 6rem"
v-if="props.actionButtons?.length"
>
عملیات
</th>
</tr>
</thead>
</table>
<!-- بدنه جدول با اسکرول عمودی -->
<div
class="overflow-y-auto"
:style="
props.tableBodyMaxHeight
? { maxHeight: props.tableBodyMaxHeight }
: {}
"
>
<table
class="w-full text-sm min-w-full"
style="table-layout: fixed; border-collapse: collapse"
>
<tbody style="display: block; width: 100%">
<tr
v-for="(item, index) in searchedItems"
:key="item.id"
class="border-t border-gray-200 dark:border-dark-primary-700 cursor-move"
:class="index % 2 === 0 ? 'even-row' : 'odd-row'"
style="display: table; width: 100%; table-layout: fixed"
:draggable="props.draggableRows"
@dragstart="props.draggableRows && onRowDragStart(item)"
>
<!-- شماره ردیف -->
<td class="p-0" style="width: 3rem">
<div
class="w-full h-full flex items-center justify-center px-4 text-sm font-medium"
:style="{
paddingTop: pyClassValue,
paddingBottom: pyClassValue,
}"
>
{{ item.rowNumber }}
</div>
</td>
<!-- سلولهای داده -->
<td
v-for="col in tableColumns"
:key="col.key"
:style="{ width: col.width ? col.width : 'auto' }"
class="p-0"
>
<UContextMenu
v-if="col.contextmenu"
:items="getMenuItemsWithHandler(col, item, index)"
:ui="{ content: 'w-48' }"
>
<div
class="w-full h-full flex items-center px-4 text-right break-words text-sm"
:style="{
paddingTop: pyClassValue,
paddingBottom: pyClassValue,
}"
@click="
!col.isLink &&
handleInteraction({
type: 'click',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
@dblclick="
handleInteraction({
type: 'dblclick',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
>
<span
v-if="col.isLink"
@click.stop="
handleInteraction({
type: 'link',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
class="text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
v-html="getCellValue(item, col)"
>
</span>
<span v-else v-html="getCellValue(item, col)"></span>
</div>
</UContextMenu>
<div
v-else
class="w-full cursor-pointer h-full flex items-center px-4 text-right break-words text-sm whitespace-normal"
:style="{
paddingTop: pyClassValue,
paddingBottom: pyClassValue,
}"
@click="
!col.isLink &&
handleInteraction({
type: 'click',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
@dblclick="
handleInteraction({
type: 'dblclick',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
>
<span
v-if="col.isLink"
@click.stop="
handleInteraction({
type: 'link',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
class="text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
v-html="getCellValue(item, col)"
>
</span>
<span v-else v-html="getCellValue(item, col)"></span>
</div>
</td>
<!-- عملیات -->
<td
v-if="props.actionButtons?.length"
class="p-0 pl-2"
style="width: 6rem"
>
<div
class="w-full h-full flex items-center justify-center px-4"
:style="{
paddingTop: pyClassValue,
paddingBottom: pyClassValue,
}"
>
<div class="flex gap-2">
<UButton
v-if="props.actionButtons.length <= 3"
v-for="btn in props.actionButtons"
:key="btn.key"
:icon="btn.icon"
size="md"
:color="getButtonColor(btn.key)"
variant="ghost"
square
@click.stop="
handleActionFromButton(btn.key, item, index, $event)
"
/>
<ClientOnly>
<UDropdownMenu
v-if="props.actionButtons.length > 3"
:items="
allActionMenuItems(
props.actionButtons,
item,
index
)
"
>
<UButton
icon="heroicons:ellipsis-vertical"
size="xs"
color="gray"
variant="ghost"
square
/>
</UDropdownMenu>
</ClientOnly>
</div>
</div>
</td>
</tr>
<!-- بدون داده -->
<tr v-if="searchedItems.length === 0">
<td
:colspan="tableColumns.length + 2"
class="py-8 text-center text-gray-400 dark:text-gray-500 text-sm"
>
دادهای یافت نشد
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- حالت موبایل: کارتها -->
<div class="md:hidden">
<div
class="overflow-y-auto px-4 py-2"
:style="
props.tableBodyMaxHeight
? { maxHeight: props.tableBodyMaxHeight }
: {}
"
>
<div class="space-y-4">
<div
v-for="(item, index) in searchedItems"
:key="item.id"
class="border border-gray-200 dark:border-dark-primary-700 rounded-lg p-4 bg-white dark:bg-dark-primary-800 shadow-sm"
:class="index % 2 === 0 ? 'even-row' : 'odd-row'"
:draggable="props.draggableRows"
@dragstart="props.draggableRows && onRowDragStart(item)"
>
<div class="space-y-3">
<!-- شماره ردیف -->
<div class="flex justify-between items-start gap-2">
<span
class="text-xs font-medium text-gray-500 dark:text-gray-400"
>
ردیف
</span>
<span
class="text-right text-sm font-medium text-dark-primary dark:text-gray-100"
>
{{ item.rowNumber }}
</span>
</div>
<!-- سایر ستونها -->
<div
v-for="col in tableColumns"
:key="col.key"
class="flex justify-between items-start gap-2"
>
<span
class="text-xs font-medium text-gray-500 dark:text-gray-400 min-w-0"
>
{{ col.title }}
</span>
<div
class="text-right flex-1 text-sm text-dark-primary dark:text-gray-100"
>
<UContextMenu
v-if="col.contextmenu"
:items="getMenuItemsWithHandler(col, item, index)"
:ui="{ content: 'w-48' }"
>
<span
v-if="col.isLink"
@click="
handleInteraction({
type: 'link',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
class="text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
v-html="getCellValue(item, col)"
></span>
<span
v-else
@click="
!col.isLink &&
handleInteraction({
type: 'click',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
@dblclick="
handleInteraction({
type: 'dblclick',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
class="cursor-pointer"
v-html="getCellValue(item, col)"
></span>
</UContextMenu>
<template v-else>
<span
v-if="col.isLink"
@click="
handleInteraction({
type: 'link',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
class="text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
v-html="getCellValue(item, col)"
></span>
<span
v-else
@click="
!col.isLink &&
handleInteraction({
type: 'click',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
@dblclick="
handleInteraction({
type: 'dblclick',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
class="cursor-pointer"
v-html="getCellValue(item, col)"
></span>
</template>
</div>
</div>
</div>
<!-- دکمههای عملیات -->
<div
class="flex justify-end gap-2 mt-4"
v-if="props.actionButtons?.length"
>
<UButton
v-if="props.actionButtons.length <= 3"
v-for="btn in props.actionButtons"
:key="btn.key"
:icon="btn.icon"
size="xs"
:color="getButtonColor(btn.key)"
variant="ghost"
square
@click.stop="
handleActionFromButton(btn.key, item, index, $event)
"
/>
<ClientOnly>
<UDropdownMenu
v-if="props.actionButtons.length > 3"
:items="
allActionMenuItems(props.actionButtons, item, index)
"
>
<UButton
icon="heroicons:ellipsis-vertical"
size="xs"
color="gray"
variant="ghost"
square
/>
</UDropdownMenu>
</ClientOnly>
</div>
</div>
</div>
</div>
<div
v-if="searchedItems.length === 0"
class="text-center py-8 text-gray-400 dark:text-gray-500 text-sm"
>
دادهای یافت نشد
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from "vue";
const props = defineProps({
tableColumns: { type: Array, required: true },
actionButtons: { type: Array, default: () => [] },
rawData: { type: Array, required: true },
cellMenuItems: { type: Array, default: () => [] },
showSearch: { type: Boolean, default: true },
tableBodyMaxHeight: { type: String, default: "80dvh" },
pagination: { type: Object, required: true },
draggableRows: { type: Boolean, default: false },
});
const emit = defineEmits(["my-table-action", "drag-start"]);
const searchQuery = ref("");
const clickTimer = ref(null);
const pyClassValue = "0.75rem"; // میتوانید از props هم بگیرید
// شماره ردیف پایدار
const rowNumberMap = ref(new Map());
const nextRowNumber = ref(1);
const getItemKey = (item, idx) => {
if (!item) return `__idx_${idx}`;
if (item.id) return String(item.id);
if (item._id) return String(item._id);
if (item._source && (item._source.id || item._source._id))
return String(item._source.id || item._source._id);
try {
return JSON.stringify(item);
} catch {
return `__idx_${idx}`;
}
};
watch(
() => props.rawData,
(newVal) => {
if (!Array.isArray(newVal)) return;
newVal.forEach((rawItem, idx) => {
const key = getItemKey(rawItem, idx);
if (!rowNumberMap.value.has(key)) {
rowNumberMap.value.set(key, nextRowNumber.value++);
}
});
},
{ immediate: true, deep: true }
);
const processedData = computed(() => {
if (!Array.isArray(props.rawData)) return [];
const offset = props.pagination?.offset ?? 0;
return props.rawData.map((item, idx) => {
const key = getItemKey(item, idx);
const computedId = item._id || item.id || key;
const rowNumber = offset + idx + 1;
return {
...item,
id: computedId,
rowNumber,
};
});
});
const searchedItems = computed(() => {
if (!searchQuery.value) return processedData.value;
const q = searchQuery.value.toLowerCase();
return processedData.value.filter((item) =>
Object.values(item).some((val) =>
String(val ?? "")
.toLowerCase()
.includes(q)
)
);
});
const getButtonColor = (key) => {
return { edit: "primary", delete: "red", clone: "green" }[key] || "gray";
};
// ====================== getCellValue اصلی ======================
const getObjectText = (schema, data) => {
let text = "";
if (schema.is_object == 1 && schema.object_text) {
Object.keys(schema.object_text).forEach((k) => {
if (data[k] !== undefined) {
if (schema.object_text[k]) text += " - " + schema.object_text[k] + ":";
text += data[k];
}
});
} else {
text = String(data);
}
return text;
};
const formatDateToPersian = (item) => {
if (!item) return "";
let date;
const num = Number(item);
if (!isNaN(num) && item.toString().length === 10) {
date = new Date(item * 1000);
} else if (typeof item === "string" && !item.includes("T00:00:00")) {
date = new Date(item + "T00:00:00");
} else {
date = new Date(item);
}
if (isNaN(date.getTime())) return item;
return date.toLocaleDateString("fa-IR");
};
const getCellValue = (rowItem, column) => {
let key = column.key;
let trancate_word = column?.trancate_word;
let value = "";
if (rowItem.highlight && key in rowItem.highlight) {
if (Array.isArray(rowItem.highlight[key])) {
value = rowItem.highlight[key][0];
} else {
value = rowItem.highlight[key];
}
return value;
}
let rowItem_data = rowItem._source ?? rowItem;
if (!rowItem_data) return "";
key = key.replace("__", ".");
if (key.includes(".")) {
const keys = key.split(".");
for (let i = 0; i < keys.length - 1; i++) {
if (rowItem_data[keys[i]] !== undefined) {
rowItem_data = rowItem_data[keys[i]];
} else {
return "";
}
}
key = keys[keys.length - 1];
}
if (column?.object_text && rowItem_data[column.key]) {
const data = rowItem_data[column.key];
if (column.is_array == 1 && Array.isArray(data)) {
data.forEach((el) => {
const text = getObjectText(column, el);
if (text) value += text + "-";
});
} else {
value = getObjectText(column, data);
}
}
if (value === "" && rowItem_data[key] !== undefined) {
value = rowItem_data[key];
}
if (value && trancate_word && trancate_word > 0) {
const words = String(value).split(" ");
if (words.length > trancate_word) {
value = words.slice(0, trancate_word).join(" ") + "...";
}
}
if (value !== "" && column?.process) {
if (column.process === "len") {
return String(value).length;
} else if (
column.process === "convert_date" ||
column.process === "convert_gdate"
) {
return formatDateToPersian(value);
}
}
if (
value !== "" &&
(key.toLowerCase().includes("date") || key.toLowerCase().includes("time"))
) {
return formatDateToPersian(value);
}
if (column?.colors && value in column.colors) {
value = `<span style="color:${column.colors[value]}">${value}</span>`;
}
return value ?? "";
};
// ====================== Action & Interaction Handlers ======================
const emitAction = (actionKey, item, index) => {
const payload = {
column: null,
item,
index,
menuItem: props.actionButtons.find((b) => b.key === actionKey),
};
emit("my-table-action", { action: actionKey, payload: payload });
};
const handleActionFromButton = (actionKey, item, index, event) => {
event.stopPropagation();
emitAction(actionKey, item, index);
};
const allActionMenuItems = (buttons, item, index) => {
if (buttons.length <= 3) return [];
return buttons.map((btn) => ({
label: btn.title || btn.key,
icon: btn.icon,
onSelect: () => emitAction(btn.key, item, index),
}));
};
const getMenuItemsWithHandler = (column, item, index) => {
return props.cellMenuItems.map((section) =>
section.map((menuItem) => ({
...menuItem,
onSelect: () => {
emit("my-table-action", {
type: "contextmenu",
column,
item,
value: getCellValue(item, column),
index,
menuItem,
});
},
}))
);
};
const handleInteraction = ({ type, column, item, value, index, event }) => {
if (type === "click" && event?.detail > 1) return;
if (type === "link") {
emit("my-table-action", { type, column, item, value, index, event });
return;
}
if (type === "dblclick") {
if (clickTimer.value) clearTimeout(clickTimer.value);
emit("my-table-action", { type, column, item, value, index, event });
return;
}
if (type === "click") {
if (clickTimer.value) clearTimeout(clickTimer.value);
clickTimer.value = setTimeout(() => {
emit("my-table-action", { type, column, item, value, index, event });
clickTimer.value = null;
}, 200);
}
};
const onRowDragStart = (item) => {
if (!props.draggableRows) return;
emit("drag-start", item);
};
</script>
<style scoped>
table {
table-layout: fixed;
}
td {
word-wrap: break-word;
word-break: break-word;
}
.even-row {
background-color: #f9fafb;
}
.odd-row {
background-color: #ffffff;
}
.dark .even-row {
background-color: #1f2937;
}
.dark .odd-row {
background-color: #111827;
}
</style>
<style>
.text__orange {
color: var(--color-text__orange);
}
</style>

View File

@ -0,0 +1,542 @@
<template>
<div class="flex flex-col sm:flex-row items-center mb-3 line-content">
<template v-for="(itemSchema, j) in props.lineSchema?.items || []" :key="j">
<div
v-if="isExistDataItems(props.itemData, itemSchema)"
class="flex items-center ms-2"
:class="itemSchema.style"
>
<label
v-if="itemSchema.label"
class="whitespace-nowrap ms-1 text-black"
:style="{ fontFamily: 'sahel-semi-bold' }"
for=""
>
{{ itemSchema.label }}
</label>
<template v-if="item_schema_link_route">
<!-- توجه: itemSchema.link_route نیاز به دسترسی دارد -->
<a
@click.prevent="openNewPage(props.itemData, itemSchema)"
@mousedown.middle.prevent="openNewPage(props.itemData, itemSchema)"
class="text-[15px] text-content"
v-html="getDataItems(props.itemData, itemSchema)"
></a>
</template>
<template v-else-if="itemSchema.isArray">
<div class="text-wrap">
<span
v-for="(item, index) in getDataItems(props.itemData, itemSchema)"
:key="index"
class="me-2"
:class="itemSchema.style"
>
<span v-if="itemSchema.isArray === 2">{{ item }} ,</span>
<span v-else>{{ item.title }} ,</span>
</span>
</div>
</template>
<template v-else-if="itemSchema.style === 'search-voice'">
<div class="w-11/12">
<div
v-for="(item, index) in getHtmlSound(props.itemData, itemSchema)"
:key="index"
class="text-[14px] text-content"
>
<div class="flex items-center mb-1">
<span
@click="playVoice(props.itemData, index, $event)"
class="search-tag me-2"
>
پخش صوت
</span>
<span v-html="item"></span>
</div>
</div>
<audio
:key="
audioSource ?? getApiLink(props.itemData._source.sound_link)
"
ref="audioPlayer"
controls
crossorigin
playsinline
>
<source :src="audioSource" type="audio/mp3" />
</audio>
</div>
</template>
<div
v-else
class="text-[14px] text-content"
v-html="getDataItems(props.itemData, itemSchema)"
></div>
</div>
</template>
</div>
</template>
<script setup>
import { ref } from "vue";
import { useNuxtApp, useRouter } from "#app";
import { persianDateAndTime } from "@/manuals/utilities.js";
// Define props
const props = defineProps({
lineSchema: {
type: Object,
default: () => ({}),
},
arrayItemData: {
type: Array,
default: () => [],
},
itemData: {
type: Object,
default: () => ({}),
},
textSearch: {
type: String,
default: "",
},
});
// Define emits
const emit = defineEmits(["openModalHandler", "click-item"]);
// Use composables and stores
const { $http: httpService } = useNuxtApp();
const router = useRouter();
// Template ref for audio element
const audioPlayer = ref(null);
// Reactive state (replaces `data()`)
const test = ref([
{ id: 3262, title: "جدول تجزیه اصطلاحات موضوع" },
{ id: 3260, title: "جدول تطبیق جدول جامعه" },
{ id: 3263, title: "جدول تجزیه اصطلاحات مقیاس" },
]);
const vuePlyrOptions = ref({
controls: ["play", "progress", "current-time", "mute", "volume", "settings"],
speed: { selected: 1, options: [0.75, 1, 1.5, 2] },
iconUrl: "/img/plyr.svg",
});
const audioSource = ref("");
// Methods (replaces `methods` object)
const openModalHandler = (item, schema) => {
emit("openModalHandler", { item, schema });
};
const getHtmlSound = (itemData, itemSchema) => {
let value = highlightKey2(itemData, itemSchema.source_key, "\n");
let items = value.split("\n");
return items;
};
const playVoice = (itemData, index, event) => {
let voice_times = itemData.voice_times;
if (!voice_times || !voice_times[index]) return;
audioSource.value = getApiLink(itemData._source.sound_link);
const selectedVoice = voice_times[index];
const start = selectedVoice.start || 0;
// استفاده از template ref به جای جستجو در DOM
const player = audioPlayer.value;
if (!player) {
console.warn("Audio element not found!");
return;
}
const initPlay = () => {
player.muted = true;
return player
.play()
.then(() => {
player.pause();
player.muted = false;
})
.catch((err) => console.warn("Init play failed:", err));
};
const playFromStart = () => {
player.currentTime = start;
return player.play().catch((err) => {
console.warn("Playback failed:", err);
});
};
if (player.readyState === 0) {
player.addEventListener(
"loadedmetadata",
() => {
initPlay().then(() => playFromStart());
},
{ once: true },
);
} else {
initPlay().then(() => playFromStart());
}
};
const getApiLink = (link) => {
if (!link || typeof link !== "string") return "";
// نکته: در Nuxt 3 متغیرهای محیطی معمولا با NUXT_PUBLIC_ شروع میشوند
let url = process.env.VUE_APP_BASE_URL + process.env.VUE_APP_API_NAME;
if (link.startsWith("/Quick/")) url += "media2" + link;
else if (link.startsWith("meidaex/")) url += link;
else url += link;
return url;
};
const isExistDataItems = (item, schema) => {
if (schema.style == "search-voice") return true;
let source_key = schema.source_key;
let hilight_key = schema.hilight_key;
if (hilight_key && item?.highlight && item?.highlight[hilight_key]?.length)
return true;
const keys = source_key?.split(",");
if (keys.length >= 2) {
for (let key of keys) {
if (item?._source[key]?.trim()) {
return true;
}
}
return false;
} else {
let value;
if (keys[0] == "voice_times") {
value = getSourceData(item?._source, "sound_link");
} else value = getSourceData(item?._source, keys[0]);
if (value) {
if (Array.isArray(value) && value.length == 0) return false;
else return true;
} else return false;
}
};
const getDataItems = (item, schema) => {
let source_key = schema.source_key;
let trancate_word = schema?.trancate_word;
let hilight_delimiter = schema?.hilight_delimiter ?? "... ";
const keys = source_key?.split(",");
let value = "";
let key = keys[0];
if (keys.length > 1) {
for (let key1 of keys) {
if (item?._source[key1]?.trim()) {
key = key1;
break;
}
}
}
value = highlightKey2(item, key, hilight_delimiter);
if (value != "") return value;
if (schema.key == "tags") {
value = textTags(item, schema.key);
} else if (schema.key == "convert_time") {
value = getSourceData(item?._source, key);
if (typeof value === "string" && value.includes(":")) {
let parts = value.split(":");
if (parts.length >= 2) {
return `${parts[0]}:${parts[1]}`;
}
}
return value;
} else {
value = getSourceData(item?._source, key);
if (trancate_word && trancate_word > 0) {
let words = value.split(" ");
if (words.length > trancate_word) {
value = words.slice(0, trancate_word).join(" ");
value += "...";
}
}
if (typeof value === "string") {
value = value.replaceAll("\n", " ");
value = value.replaceAll("\t", "");
}
if (schema?.process) {
if (schema.process == "convert_date") {
return formatDateToPersian(value);
} else if (schema.process == "convert_gdate") {
return convertTo3DateLang(value);
}
} else if (schema.key == "date_create") {
return formatDateToPersian(value);
}
}
if (value == "") {
value = "--";
}
return value;
};
const convertTo3DateLang = (date) => {
const resFa = persianDateAndTime(date, "fa-IR");
const resAr = persianDateAndTime(date, "fa-u-ca-islamic-umalqura");
const parsedDate =
`${resFa.weekday.short} ${resFa.year.numeric}/${resFa.month.numeric}/${resFa.day.numeric}` +
` - ${resAr.day.numeric} ${resAr.month.short} ${resAr.year.numeric}`;
return parsedDate;
};
const formatDateToPersian = (item) => {
let date;
let num = Number(item);
if (!isNaN(num)) {
if (item.toString().length == 10) {
date = new Date(item * 1000);
}
} else if (!item.includes("T00:00:00")) item = item + "T00:00:00";
date = new Date(item);
let date_f = date.toLocaleDateString("fa-IR");
return date_f;
};
const textTags = (item, key) => {
let text = "";
if (Array.isArray(item._source[key])) text = item._source[key].join("، ");
else text = item._source[key];
return text;
};
const highlightKey2 = (
item,
key,
hilight_delimiter = "...",
posfix = ["fa", "ph"],
) => {
var text = "";
if (item.highlight) {
let key_highlight = key;
let i = 0;
while (i < posfix.length) {
if (key_highlight in item.highlight) break;
key_highlight = key + "." + posfix[i];
i++;
}
if (key_highlight in item.highlight) {
let value1 = "";
value1 = item.highlight[key_highlight];
if (Array.isArray(value1)) text = value1.join(hilight_delimiter);
else text = value1;
}
}
return text;
};
const highlightKey = (item, key1, key2 = "", key3 = "") => {
var text = "";
if (item.highlight) {
if (item.highlight[key1]) text = item.highlight[key1].join("... ");
else if (key2 && item.highlight[key2])
text = item.highlight[key2].join("... ");
else if (key3 && item.highlight[key3])
text = item.highlight[key3].join("... ");
}
if (text == "") {
if (item._source[key1]) text = item._source[key1];
else if (key2 && item._source[key2]) text = item._source[key2];
else if (key3 && item._source[key3]) text = item._source[key3];
if (text.length > 500) text = text.substring(0, 500);
}
return text;
};
const openNewPage = (item, schema) => {
let cloneItem = { ...item };
if (schema.link_route.length == 0) return;
if (schema.link_route.url_key) {
window.open(item._source.url, "_blank");
} else {
let cloneList = [];
props.arrayItemData.forEach((item) => {
cloneList.push({
_id: item._id,
_source: {
id: item._id,
title: item?._source?.title ?? item?._source?.book_title,
qanon_title: item?._source?.qanon_title,
qanon_id: item?._source?.qanon_id,
},
});
});
localStorage.setItem("myList", JSON.stringify(cloneList));
localStorage.setItem("myItem", JSON.stringify(cloneItem));
let key = "";
let query_key = "";
let query_value = "";
let keys = "";
let id_route = "";
let id_route2 = "";
let keyName = schema?.link_route.id;
let query = {};
if (process.env.VUE_APP_BUILD_NAME != "majles") {
query = { searchtext: props.textSearch ?? undefined };
}
if (schema.link_route.id2) {
id_route2 = getSourceData(item, schema.link_route.id2);
}
id_route = getSourceData(item, schema.link_route.id);
if (schema.link_route?.query) {
let querys = schema.link_route.query;
if (typeof querys === "string") {
keys = schema.link_route?.query.split("=");
if (keys.length >= 2) {
query_key = keys[0];
query_value = keys[1];
}
if (query_key && query_value) {
query[query_key] = query_value;
}
} else {
let newObject = {};
for (const [key, value] of Object.entries(querys)) {
if (value in item) {
newObject[key] = item[value];
} else {
newObject[key] = item?._source[value];
}
}
Object.assign(query, newObject);
}
}
if (schema.link_route?.key_item || schema.link_route.key) {
if (schema.link_route?.key_item)
key = getSourceData(item, schema.link_route.key_item);
else key = schema.link_route.key;
}
const routeData = router.resolve({
name: schema.link_route.name,
params: {
id: id_route,
...(id_route2 && { id2: id_route2 }),
key: key,
},
query: query,
});
emit("click-item", { id: id_route });
window.open(routeData.href, "_blank");
}
};
const getArrayData = (itemData, collapseItem) => {
if (!collapseItem.array_key) return [itemData];
let sourceData = getSourceData(itemData, collapseItem.array_key);
if (!Array.isArray(sourceData)) return [itemData];
return sourceData;
};
const getSourceData = (itemData, key) => {
let sourceData = itemData;
key?.split(".").forEach((k) => {
let keyName = k;
if (keyName in sourceData) {
sourceData = sourceData[keyName];
} else if (sourceData?._source && keyName in sourceData?._source) {
sourceData = sourceData?._source[keyName];
} else {
sourceData = "";
}
});
return sourceData;
};
</script>
<style lang="scss">
.search-tag {
cursor: pointer;
color: #96a5b5;
white-space: nowrap;
padding: 0 5px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: #fff;
border: 1px solid #e4dfd8;
height: 20px;
margin-left: 8px;
&:hover {
color: black;
border-color: black;
}
}
</style>
<style lang="scss" scoped>
.search-tag {
cursor: pointer;
color: #96a5b5;
white-space: nowrap;
padding: 0 5px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: #fff;
border: 1px solid #e4dfd8;
height: 20px;
margin-left: 8px;
&:hover {
color: black;
border-color: black;
}
}
.search-title {
cursor: pointer;
color: rgb(59, 130, 246) !important;
&:hover {
color: rgb(59, 130, 246) !important;
text-decoration: underline !important;
}
}
.search-label {
a {
color: #00b6e3;
}
span {
color: #00b6e3;
}
}
.search-body {
display: -webkit-box !important;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 25px;
color: #000;
}
.text-content {
text-align: justify;
}
</style>

59
app/composables/useApiCache.js Executable file
View File

@ -0,0 +1,59 @@
// composables/useApiCache.js
export const useApiCache = () => {
// داده‌های ذخیره شده (key -> response data)
const cacheData = useState("cacheData", () => ({}));
// درخواست‌های در حال اجرا (key -> Promise)
const pendingRequests = useState("pendingRequests", () => ({}));
// زمان انقضا برای هر key (ms)
const ttlMap = useState("ttlMap", () => ({}));
// ---- متدهای ساده و قابل فهم ----
const hasData = (key) => key in cacheData.value;
const getData = (key) => cacheData.value[key];
const saveData = (key, data) => (cacheData.value[key] = data);
const removeData = (key) => {
delete cacheData.value[key];
delete ttlMap.value[key];
};
const clearAllData = () => {
Object.keys(cacheData.value).forEach((k) => delete cacheData.value[k]);
Object.keys(pendingRequests.value).forEach(
(k) => delete pendingRequests.value[k],
);
Object.keys(ttlMap.value).forEach((k) => delete ttlMap.value[k]);
};
const getPending = (key) => pendingRequests.value[key];
const setPending = (key, promise) => (pendingRequests.value[key] = promise);
const clearPending = (key) => delete pendingRequests.value[key];
const saveDataWithTTL = (key, data, ttlSeconds = 0) => {
saveData(key, data);
if (ttlSeconds > 0) {
ttlMap.value[key] = Date.now() + ttlSeconds * 1000;
} else {
delete ttlMap.value[key];
}
};
const isExpired = (key) => {
const expireTime = ttlMap.value[key];
if (!expireTime) return false;
return Date.now() > expireTime;
};
return {
hasData,
getData,
saveData,
removeData,
clearAllData,
getPending,
setPending,
clearPending,
saveDataWithTTL,
isExpired,
};
};

View File

@ -0,0 +1,87 @@
// composables/useCachedRequest.js
import { useApiCache } from "~/composables/useApiCache";
const tryParseJSON = (str) => {
if (typeof str !== "string") return str;
if (!str.trim()) return null;
try {
return JSON.parse(str);
} catch (e) {
return str; // اگر parse نشد، رشته را همون‌طور برگردون
}
};
export const useCachedRequest = () => {
const cache = useApiCache();
const { $http: httpService } = useNuxtApp();
// ساختن کلید ساده و یکتا برای هر request
const getRequestKey = (config = {}) => {
const method = (config.crud || "GET").toUpperCase();
const payloadNorm = tryParseJSON(config.payload) ?? null;
return `${method}:${config.url}:${JSON.stringify(payloadNorm)}`;
};
// درخواست با cache و dedupe
const fetchRequest = async (config = {}) => {
if (!config?.url) throw new Error("apiConfig.url is required");
const key = getRequestKey(config);
// پاک کردن cache منقضی شده
if (cache.hasData(key) && cache.isExpired(key)) cache.removeData(key);
// 1. اگر قبلاً داده موجود است → برگردان
if (cache.hasData(key)) return cache.getData(key);
// 2. اگر درخواست در حال اجراست → promise را await کن (dedupe)
const pending = cache.getPending(key);
if (pending) return await pending;
// 3. ایجاد request جدید
const promise = (async () => {
let response;
const method = (config.crud || "GET").toUpperCase();
if (method === "POST") {
const payload = tryParseJSON(config.payload) ?? {};
response = await httpService.postRequest(config.url, payload);
} else {
response = await httpService.getRequest(config.url);
}
const data = response?.data ?? response;
// ذخیره با TTL در صورت وجود
const ttl = config.ttlSeconds || 0;
if (ttl > 0) {
cache.saveDataWithTTL(key, data, ttl);
} else {
cache.saveData(key, data);
}
return data;
})();
cache.setPending(key, promise);
try {
return await promise;
} finally {
cache.clearPending(key);
}
};
// پاکسازی دستی cache برای logout یا تغییر داده‌ها
const invalidate = (configOrKey) => {
const key =
typeof configOrKey === "string"
? configOrKey
: getRequestKey(configOrKey || {});
cache.removeData(key);
};
const clearAll = () => cache.clearAllData();
return { fetchRequest, invalidate, clearAll };
};

View File

@ -0,0 +1,78 @@
// @/composables/useFormBuilder.js
import { useNuxtApp } from "#app";
export function useElpService(props, emit) {
const { $moment } = useNuxtApp(); // فرض می‌کنیم $moment در Nuxt plugin ثبت شده
const { $http: httpService } = useNuxtApp();
const toast = useToast();
///-----------------------------------------------------
///--- یک فیلد را بروزرسانی می کند
///-----------------------------------------------------
async function uepUpdateField(doc_id, payload, key = "sanad") {
if (!doc_id) {
return;
}
let url = elpApi.base.update_field;
url = url.replace("{{type_name}}", key);
url = url.replace("{{doc_id}}", doc_id);
return await httpService
.postRequest(url, payload)
.then(() => {
toast.add({
title: "موفق",
description: "عملیات با موفقیت انجام شد.",
icon: "i-lucide-calendar-days",
});
return true;
})
.catch(() => {
toast.add({
title: "خطا",
description: "خطا در انجام دوباره تلاش کنید",
color: "red",
});
return undefined;
});
}
///-----------------------------------------------------
///--- با توجه به یک فیلد، مقدار محاسبه شده ای بر میگردند مثلا : بیشترین مقدار یا مجموع یا ...
///-----------------------------------------------------
async function uepGetComputeField(
key = "mnsanad",
property_key = "meet_no",
compute_key = "max",
filters = {}
) {
let payload = {
compute_key,
property_key,
filters,
};
let url = elpApi.base.compute_field;
url = url.replace("{{type_name}}", key);
return await httpService
.postRequest(url, payload)
.then((res) => {
return res;
})
.catch(() => {
toast.add({
title: "خطا",
description: "خطا در انجام عملیات، دوباره تلاش کنید",
color: "red",
});
return undefined;
});
}
///-----------------------------------------------------
///-----------------------------------------------------
return {
uepUpdateField,
uepGetComputeField,
};
}

View File

@ -436,6 +436,5 @@
},
"defaultAlignment": "right"
},
"welcomeContent": "<div style=\"text-align: right;\">\n <p>فلسفه اعم از همه علوم و معارف است، زيرا موضوع آن (موجود) عام ترين موضوعات و در برگيرنده همه چيزهاست. علوم كلًاّ از حيث ثبوت موضوع متوقف بر فلسفه اند، اما فلسفه در ثبوت موضوع خود بر هيچ يك از علوم مبتنى نيست</p> </div>"
"welcomeContent": "<div style=\"text-align: right;\">\n <p>فلسفه اعم از همه علوم و معارف است، زيرا موضوع آن (موجود) عام ترين موضوعات و در برگيرنده همه چيزهاست. علوم كلًاّ از حيث ثبوت موضوع متوقف بر فلسفه اند، اما فلسفه در ثبوت موضوع خود بر هيچ يك از علوم مبتنى نيست</p>\n <details open>\n <summary>اکاردیون خوش‌آمدگویی</summary>\n <p>این متن داخل اکاردیون دیفالت باز است و می‌توانی محتوای خودت را اینجا اضافه کنی.</p>\n </details>\n</div>"
}

View File

@ -20,26 +20,29 @@
],
"tableColumns": [
{
"key": "branch",
"title": "دوره",
"isLink": true,
"width": "18%",
"contextmenu": true
"key": "previous_info.qanon_title",
"title": "قانون مقدم",
"width": "3",
"trancate_word": 10
},
{ "key": "title", "title": "عنوان", "width": "15%", "contextmenu": true },
{ "key": "subtitle", "title": "عنوان محتوایی", "width": "25%" },
{ "key": "meet_code", "title": "کد جلسه", "width": "12%" },
{ "key": "previous_info.full_path", "title": "ماده مقدم", "width": "1" },
{
"key": "begin_date",
"title": "تاریخ",
"isLink": true,
"width": "12%",
"contextmenu": true
"key": "next_info.qanon_title",
"title": "قانون موخر",
"width": "3",
"trancate_word": 10
},
{ "key": "video_count", "title": "ت فیلم", "width": "5%" },
{ "key": "photo_count", "title": "ت تصویر", "width": "5%" },
{ "key": "sound_count", "title": "ت صوت", "width": "5%" },
{ "key": "file_count", "title": "ت فایل", "width": "5%" }
{ "key": "next_info.full_path", "title": "ماده موخر", "width": "1" },
{
"key": "subject_unity.main_type",
"title": "وضعیت وحدت موضوع",
"width": "2"
},
{
"key": "conflict_relation_identification.main_type",
"title": "تعارض",
"width": "2"
}
],
"menuItems": [
[

247
app/json/refineCodes.json Normal file
View File

@ -0,0 +1,247 @@
[
{
"other_id": 1,
"title": "آب",
"value": 1
},
{
"other_id": 2,
"title": "آمار، برنامه و بودجه",
"value": 2
},
{
"other_id": 3,
"title": "آموزش عالی، پژوهش و فناوری",
"value": 3
},
{
"other_id": 4,
"title": "آموزش و پرورش",
"value": 4
},
{
"other_id": 5,
"title": "آیین دادرسی اداری",
"value": 5
},
{
"other_id": 6,
"title": "آیین دادرسی مدنی",
"value": 6
},
{
"other_id": 7,
"title": "آیین دادرسی کیفری",
"value": 7
},
{
"other_id": 8,
"title": "اداری و استخدامی",
"value": 8
},
{
"other_id": 9,
"title": "اطلاعات، امنیت و نظامی، انتظامی",
"value": 9
},
{
"other_id": 10,
"title": "اموال و معاملات دولتی",
"value": 10
},
{
"other_id": 11,
"title": "انتخابات",
"value": 11
},
{
"other_id": 12,
"title": "اوقاف، اماکن دینی و امور مذهبی",
"value": 12
},
{
"other_id": 13,
"title": "ایثارگران",
"value": 13
},
{
"other_id": 14,
"title": "بانکداری",
"value": 14
},
{
"other_id": 15,
"title": "برق و انرژی‌های نو",
"value": 15
},
{
"other_id": 16,
"title": "بیمه",
"value": 16
},
{
"other_id": 17,
"title": "تأمین اجتماعی",
"value": 17
},
{
"other_id": 18,
"title": "تجارت",
"value": 18
},
{
"other_id": 19,
"title": "تشکل‌های مدنی و احزاب",
"value": 19
},
{
"other_id": 20,
"title": "تشکیلات و امور اداری قوه قضائیه",
"value": 20
},
{
"other_id": 21,
"title": "تعاون",
"value": 21
},
{
"other_id": 22,
"title": "تعزیرات حکومتی",
"value": 22
},
{
"other_id": 23,
"title": "تقسیمات کشوری و مدیریت محلی",
"value": 23
},
{
"other_id": 24,
"title": "ثبت اسناد و املاک",
"value": 24
},
{
"other_id": 25,
"title": "پولی و مالی",
"value": 25
},
{
"other_id": 26,
"title": "حمل و نقل",
"value": 26
},
{
"other_id": 27,
"title": "خانواده",
"value": 27
},
{
"other_id": 28,
"title": "رسانه",
"value": 28
},
{
"other_id": 29,
"title": "سلامت",
"value": 29
},
{
"other_id": 30,
"title": "صنعت",
"value": 30
},
{
"other_id": 31,
"title": "فرهنگ و هنر",
"value": 31
},
{
"other_id": 32,
"title": "فناوری اطلاعات و ارتباطات",
"value": 32
},
{
"other_id": 33,
"title": "کار",
"value": 33
},
{
"other_id": 34,
"title": "کشاورزی و منابع طبیعی",
"value": 34
},
{
"other_id": 35,
"title": "کیفری",
"value": 35
},
{
"other_id": 36,
"title": "مالکیت فکری",
"value": 36
},
{
"other_id": 37,
"title": "مالیات",
"value": 37
},
{
"other_id": 38,
"title": "محاسبات عمومی",
"value": 38
},
{
"other_id": 39,
"title": "محیط زیست",
"value": 39
},
{
"other_id": 40,
"title": "مدنی و امور حسبی",
"value": 40
},
{
"other_id": 41,
"title": "مسکن و شهرسازی",
"value": 41
},
{
"other_id": 42,
"title": "مصرف",
"value": 42
},
{
"other_id": 43,
"title": "معدن",
"value": 43
},
{
"other_id": 44,
"title": "مناطق آزاد و ویژه اقتصادی",
"value": 44
},
{
"other_id": 45,
"title": "میراث فرهنگی و گردشگری",
"value": 45
},
{
"other_id": 46,
"title": "نظام قانون‌گذاری",
"value": 46
},
{
"other_id": 47,
"title": "نظام‌های صنفی و حرفه‌ای",
"value": 47
},
{
"other_id": 48,
"title": "نفت و گاز",
"value": 48
},
{
"other_id": 49,
"title": "ورزش",
"value": 49
}
]

View File

@ -4,23 +4,20 @@
"id": "MainList",
"key": "MainList",
"label": "فهرست",
"icon": "emojione-closed-book",
"develop": 0,
"icon": "i-lucide-list",
"active": true
},
{
"id": "RuleEdit",
"label": "احکام",
"key": "RuleEdit",
"develop": 0,
"icon": "i-mdi-library-outline"
"icon": "i-lucide-scroll-text"
},
{
"id": "RelationEdit",
"key": "RelationEdit",
"icon": "emojione-orange-book",
"develop": 0,
"label": "روابط"
"label": "روابط",
"icon": "i-lucide-network"
}
]
}

773
app/manuals/utilities.js Executable file
View File

@ -0,0 +1,773 @@
// import Vue from "vue";
// const crypto = require("crypto");
import exportFromJSON from "export-from-json";
// import store from "@store/store";
export const isLoginRemoved = () => process.env.VUE_APP_LOGIN_REMOVE == 1;
// util to export to excel file using plugin
// https://github.com/zheeeng/export-from-json
export const convertJsonToExcelUsingPlugin = async (
data,
fileName = "download"
) => {
// 'txt'(default), 'css', 'html', 'json', 'csv', 'xls', 'xml'
const exportType = exportFromJSON.types.xls;
const withBOM = true;
return await exportFromJSON({ data, fileName, exportType, withBOM });
};
// util to export to excel file manually
export const exportJsonToExcelManually = (JSONData, FileTitle, ShowLabel) => {
//If JSONData is not an object then JSON.parse will parse the JSON string in an Object
var arrData = typeof JSONData != "object" ? JSON.parse(JSONData) : JSONData;
var CSV = "";
//This condition will generate the Label/Header
if (ShowLabel) {
var row = "";
//This loop will extract the label from 1st index of on array
for (var index in arrData[0]) {
//Now convert each value to string and comma-seprated
row += encodeURI(index) + ",";
}
row = row.slice(0, -1);
//append Label row with line break
CSV += row + "\r\n";
}
//1st loop is to extract each row
for (var i = 0; i < arrData.length; i++) {
var row = "";
//2nd loop will extract each column and convert it in string comma-seprated
for (var index in arrData[i]) {
row += '"' + arrData[i][index] + '",';
}
row.slice(0, row.length - 1);
//add a line break after each row
CSV += row + "\r\n";
}
if (CSV == "") {
alert("Invalid data");
return;
}
//Generate a file name
// application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8;
// text/csv;charset=utf-8;
// var csvContent = "data:text/csv;charset=utf-8,%EF%BB%BF" + encodeURI(csvContent);
var filename = FileTitle;
var blob = new Blob([CSV], {
type: "text/csv;charset=utf-8,BOM",
});
if (navigator.msSaveBlob) {
// IE 10+
navigator.msSaveBlob(blob, filename);
} else {
var link = document.createElement("a");
if (link.download !== undefined) {
// feature detection
// Browsers that support HTML5 download attribute
var url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.style = "visibility:hidden";
link.download = filename + ".csv";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
};
// util to delay promise
export const wait = (ms) => {
return (x) => {
return new Promise((resolve) => setTimeout(() => resolve(x), ms));
};
};
// util to createObjectByNewProperties
export const createObjectByNewProperties = (list, allowedProperties) => {
const properties = [];
list.forEach((item, index) => {
const filtered = Object.keys(item)
.filter((key) => allowedProperties.includes(key))
.reduce((obj, key) => {
obj[key] = item[key];
return obj;
}, {});
properties[index] = filtered;
});
return properties;
};
// util to convert digits to character
export const toNumbersInCharacters = (number) => {
const persianNumbers = ["۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹", "۰"];
const arabicNumbers = ["١", "٢", "٣", "٤", "٥", "٦", "٧", "٨", "٩", "٠"];
const englishNumbers = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
const persianNumbersInCharacters = [
"یک",
"دو",
"سه",
"چهار",
"پنج",
"شش",
"هفت",
"هشت",
"نه",
"صفر",
];
// only replace english number with persian number character
return persianNumbersInCharacters[englishNumbers.indexOf(number)];
// convert all numbers to persian digits
// return str.split("").map(c =>
// persianNumbersInCharacters[englishNumbers.indexOf(c)] ||
// persianNumbersInCharacters[englishNumbers.indexOf(c)] || c).join("")
};
// util to handle if img src is not valid
export const handleImageSrcOnError = ({ target }, isUserAvatar = true) => {
if (isUserAvatar) target.classList.add("human-avatar");
target.classList.add("error");
};
// util to convert date to local date and time strng;
export const persianDateAndTime = (stringDate, local = "fa-IR") => {
let date = new Date(stringDate);
let jsonDate = {
weekday: {
long: date.toLocaleDateString(local, { weekday: "long" }),
short: date.toLocaleDateString(local, { weekday: "short" }),
narrow: date.toLocaleDateString(local, { weekday: "narrow" }),
},
era: {
long: date.toLocaleDateString(local, { era: "long" }),
short: date.toLocaleDateString(local, { era: "short" }),
narrow: date.toLocaleDateString(local, { era: "narrow" }),
},
timeZoneName: {
long: date.toLocaleDateString(local, { timeZoneName: "long" }),
short: date.toLocaleDateString(local, { timeZoneName: "short" }),
},
year: {
numeric: date.toLocaleDateString(local, { year: "numeric" }),
"2-digit": date.toLocaleDateString(local, { year: "2-digit" }),
},
month: {
numeric: date.toLocaleDateString(local, { month: "numeric" }),
"2-digit": date.toLocaleDateString(local, { month: "2-digit" }),
long: date.toLocaleDateString(local, { month: "long" }),
short: date.toLocaleDateString(local, { month: "short" }),
narrow: date.toLocaleDateString(local, { month: "narrow" }),
},
day: {
numeric: date.toLocaleDateString(local, { day: "numeric" }),
"2-digit": date.toLocaleDateString(local, { day: "2-digit" }),
},
hour: {
numeric: date.toLocaleTimeString(local, { hour: "numeric" }),
"2-digit": date.toLocaleTimeString(local, { hour: "2-digit" }),
},
minute: {
numeric: date.toLocaleTimeString(local, { minute: "numeric" }),
"2-digit": date.toLocaleTimeString(local, { minute: "2-digit" }),
},
second: {
numeric: date.toLocaleTimeString(local, { second: "numeric" }),
"2-digit": date.toLocaleTimeString(local, { second: "2-digit" }),
},
};
return jsonDate;
};
// util to format numbers to local curency
export const formatNumber = (price = "") => {
return new Intl.NumberFormat("fa-IR").format(price);
};
export const isValidHttpUrl = (string) => {
let url;
try {
url = new URL(string);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
};
export const addJsCssFileToDom = (fileUrl, fileType, uuid) => {
let targetElement =
fileType == "js" ? "script" : fileType == "css" ? "link" : "none";
let targetAttr =
fileType == "js" ? "src" : fileType == "css" ? "href" : "none";
let node = document.createElement(targetElement);
node.setAttribute(targetAttr, fileUrl);
node.setAttribute("id", targetElement + "-" + uuid);
document.head.appendChild(node);
};
export const removeJsCssFileFromDom = (fileType, uuid) => {
let targetElement =
fileType == "js" ? "script" : fileType == "css" ? "link" : "none";
document.getElementById(targetElement + "-" + uuid)?.remove();
};
export const clearBodyClass = (className = undefined) => {
if (className) document.querySelector("html").replace(className, "");
else document.querySelector("html").removeAttribute("class");
};
export const redirectToExternalLink = (href, openInNewTab) => {
if (href) {
if (isValidHttpUrl(href) && openInNewTab) window.open(href, "_blank");
else if (isValidHttpUrl(href) && !openInNewTab) window.location.href = href;
else if (!isValidHttpUrl(href) && openInNewTab) {
if (href.includes("www.")) window.open("http://" + href, "_blank");
} else if (!isValidHttpUrl(href) && !openInNewTab) {
if (href.includes("www.")) window.location.href = "http://" + href;
}
}
};
export const extractAllQueryParams = (href) => {
return new URLSearchParams(href);
// return new Proxy(new URLSearchParams(href), {
// get: (searchParams, prop) => searchParams.get(prop),
// });
};
export const makeQueryParams = (url, searchParams) => {
var query = new URLSearchParams();
// (A) URL SEARCH PARAMS OBJECT TO QUICKLY BUILD QUERY STRING
Object.keys(searchParams).forEach((key) =>
query.append(key, searchParams[key])
);
// (B) CONVERT TO STRING, APPEND TO URL
url += "?" + query.toString();
return url;
};
// export const decryptData = (
// data,
// key = "fTjWnZr4u7x!A%D*G-KaNdRgUkXp3s6v",
// method = "AES-256-CBC",
// iv = "poaskq2234??@35."
// ) => {
// let decipher = crypto.createDecipheriv(method, key, iv);
// let decrypted = decipher.update(data, "base64", "utf8");
// let dd = decrypted + decipher.final("utf8");
// return JSON.parse(dd);
// };
// util to handle response errors
export const handleErrors = (error) => {
return new Promise((resolve, reject) => {
/*
401: The HTTP 401 Unauthorized response status code indicates that
the client request has not been completed because it lacks
valid authentication credentials for the requested resource.
403: The HTTP 403 Forbidden response status code indicates that
the server understands the request but refuses to authorize it.
404: The HTTP 404 Not Found response status code indicates that
the server cannot find the requested resource
405: The HyperText Transfer Protocol (HTTP) 405 Method Not Allowed
response status code indicates that the server knows the
request method, but the target resource doesn't support this method
406: Not Acceptable
*/
try {
let res = error.response;
if (res) {
if (res.status === 401) {
toast.add({
title: "خطا!",
description: res.data.message,
});
// router.push({ name: 'login' })
} else if (res.status === 403) {
toast.add({
title: "خطا!",
description: res.data.message,
});
} else if (res.status === 404) {
toast.add({
title: "خطا!",
description: res.data.message,
});
// router.push('/404')
} else if (res.status === 405) {
toast.add({
title: "خطا!",
description: res.data.message,
});
// router.push('/404')
} else if (res.status === 406) {
toast.add({
title: "خطا!",
description: res.data.message,
});
// router.push('/404')
} else if (res.status === 500) {
toast.add({
title: "خطا!",
description: res.data.message ?? res.data,
});
} else {
toast.add({
title: "خطا!",
description: res.data.message,
});
}
resolve(error.response);
} else {
let message = "خطا!!!";
// if (error) {
// message = error.message ?? error;
// }
// else
// {
// message = "خطا!!!"
// }
reject(new Error(message));
// text: error.stack,
toast.add({
title: message,
});
}
} catch (error) {
let message = "خطا!!!";
message = error?.message ?? message;
return reject(new Error(message));
}
});
};
function countDownTimer(timeInSecond = 120) {
var timeleft = timeInSecond;
var downloadTimer = setInterval(function () {
if (timeleft <= 0) {
clearInterval(downloadTimer);
// document.getElementById("countdown").innerHTML = "Finished";
} else {
// document.getElementById("countdown").innerHTML =
// timeleft + " seconds remaining";
}
timeleft -= 1;
}, 1000);
return timeleft;
}
///////////////////////////////////////
///// html functions
///////////////////////////////////////
export function getSelectionHtmlRange() {
var sel;
if (window.getSelection) {
sel = window.getSelection();
if (sel.rangeCount) {
return sel.getRangeAt(0);
}
} else if (document.selection) {
return document.selection.createRange();
}
return null;
}
export function getSelectedHtmlInfo() {
const selectRange = getSelectionHtmlRange();
if (!selectRange) return {};
// if (selectRange.startContainer !== selectRange.endContainer) {
// return;
// }
let begin = 0;
let strBegin = "";
let prevFullText = "";
if (selectRange.startOffset > 0)
strBegin = selectRange.startContainer.textContent.substring(
0,
selectRange.startOffset - 1
);
// برای وقتی که در تگ قبلی ، شماره کلمه انتها ذخیره شده است و نیازی به بررسی قبل از آن نیست
if (
selectRange.commonAncestorContainer.previousSibling &&
selectRange.commonAncestorContainer.previousSibling.dataset?.end
) {
begin =
parseInt(
selectRange.commonAncestorContainer.previousSibling.dataset.end
) + 1;
begin += splitWord(strBegin).length;
} else {
prevFullText = getPreviousHtmlText(selectRange.startContainer, selectRange);
begin += splitWord(prevFullText).length;
}
let selectedText = selectRange.startContainer.textContent.substring(
selectRange.startOffset,
selectRange.endOffset
);
let end = begin + splitWord(selectedText).length - 1;
///////////////////////////////////////////////////////
///// استخراج کلمه کلیک شده و دو کلمه بعدش
///// برای حالتی که متنی انتخاب نشده و فقط کلیک شده نیاز هست
let node = selectRange.startContainer;
const fullText = node.textContent;
const offset = selectRange.startOffset;
const words = fullText.split(/\s+/);
let totalChars = 0;
let wordIndex = -1;
let wordsClicked = "";
/// چون احتمال داره وسط کلمه کلیک شده باشه، قبل کلمه کلیک شده می ایستیم
for (let i = 0; i < words.length; i++) {
totalChars += words[i].length + 1;
if (offset < totalChars) {
wordIndex = i;
break;
}
}
if (wordIndex !== -1) {
wordsClicked =
words[wordIndex] +
" " +
(words[wordIndex + 1] || "") +
" " +
(words[wordIndex + 2] || "");
}
///////////////////////////////////////////////////////
let info = {
begin: begin,
end: end,
selectedText: selectedText,
prevTextBegin: strBegin,
texts: wordsClicked,
// prevTextFull: prevFullText,
};
return info;
}
export function getPreviousHtmlText(node, selectRange = null) {
let text = "";
if (!node) return text;
else if (selectRange && node == selectRange.startContainer) {
if (selectRange.startOffset > 0)
text = selectRange.startContainer.textContent.substring(
0,
selectRange.startOffset - 1
);
// else
// text = selectRange.startContainer.textContent;
} else text = node.textContent;
if (node.previousSibling)
text = getPreviousHtmlText(node.previousSibling, null) + text;
return text;
}
////////////////////////////////////////////////////////////////////
export function splitWord(text) {
let items = text.split(
/[\t\n\r\.\'\"«»\)\(\]\[\{\}\%\#\$\*\<\>\/,;،؛ \-!?:٭؟]+/
);
// let items = text.split("\t\n\r.'\"«»)(][{}%#$*<>/,;،؛ -!?:٭؟")
return items;
}
export function normalizeText(
text,
options = { charArNormal: true, errorLine: true }
) {
// //defualt
// options = {
// charArNormal: true,
// errorLine: true,
// alphabetsNormal: false,
// emptylinesNormal: false,
// hamzehNormal: false,
// spaceCorrection: false,
// trimLine: false
// };
text = text.trim();
//اصلاح خطاهای متن در کد خط
if (options?.errorLine) {
text = text.replaceAll("\\n", "\n");
text = text.replaceAll("\\r", "\r");
text = replaceRegText("(\r\n)+", "\n", text);
text = text.replaceAll("\r", "\n");
//شنبهها ==> شنبه‌ها
//رسیدگیهای ==> رسیدگی‌های
// text = replaceRegText("(ه)(ها)", /\1\2/gm, text);
}
// یکسان سازی اختلاف حروف کیبوردهای مختلف - کدهای مختلف ولی نمایش یکسان
if (options?.alphabetsNormal) {
text = replaceAlphabets(text);
}
//حذف خطوط و فاصله‌های خالی
if (options?.emptylinesNormal) {
text = replaceRegText("( )+", " ", text);
text = replaceRegText("(\n)+", "\n", text);
}
// نرمال کردن حروف اختلافی عربی و فارسی
if (options?.charArNormal) {
text = text.replaceAll("ي", "ی");
text = text.replaceAll("ك", "ک");
}
// پرش از اختلافات همزه‌ای
if (options?.hamzehNormal) {
text = replaceRegText("ئ|ﺋ", "ی", text);
text = replaceRegText("ؤ|ﺅ", "و", text);
text = replaceRegText("ﺔ|ۀ|ة", "ه", text);
text = replaceRegText("إ|أ", "ا", text);
}
if (options?.dateNormal) {
text = dateNormalize(text);
}
if (options?.spaceCorrection) {
text = spaceCorrection(text);
}
// حذف خطوط اضافی اول و انتهای متن
if (options?.trimLine) text = text.replace(/^\s+|\s+$/g, "");
text = text.trim();
return text;
}
//برعکس بودن تاریخ ها را درست میکند
export function dateNormalize(text, forWord = false) {
if (forWord) {
// فرمت YYYY/MM/DD یا YYYY-MM-DD
text = replaceRegText(
/(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})/,
(_, year, month, day) =>
`${day.padStart(2, "0")}/${month.padStart(2, "0")}/${year}`,
text
);
// // فرمت DD/MM/YYYY یا DD-MM-YYYY
// text = replaceRegText(
// /(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/,
// (_, day, month, year) =>
// `${year}/${month.padStart(2, "0")}/${day.padStart(2, "0")}`,
// text
// );
} else {
// فرمت YYYY/MM/DD یا YYYY-MM-DD
text = replaceRegText(
/(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})/,
(_, year, month, day) =>
`${year}/${month.padStart(2, "0")}/${day.padStart(2, "0")}`,
text
);
// فرمت DD/MM/YYYY یا DD-MM-YYYY
text = replaceRegText(
/(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/,
(_, day, month, year) =>
`${year}/${month.padStart(2, "0")}/${day.padStart(2, "0")}`,
text
);
}
// // فرمت MM/DD/YYYY
// text = replaceRegText(
// /(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/,
// (_, month, day, year) =>
// `${year}/${month.padStart(2, "0")}/${day.padStart(2, "0")}`,
// text
// );
return text;
}
export function replaceAlphabets(text) {
let res = text;
res = res.replaceAll("\u00AD", "\u200C");
res = res.replaceAll("\u00AC", "\u200C"); // نیم اسپیس خاص
res = replaceRegText("ﺁ|آ", "آ", res);
res = replaceRegText("|ٱ|", "ا", res); //
res = replaceRegText("ٲ|أ|ﺄ|ﺃ", "أ", res);
res = replaceRegText("ﺈ|ﺇ", "إ", res);
res = replaceRegText("ﺐ|ﺏ|ﺑ|ﺒ", "ب", res);
res = replaceRegText("ﭖ|ﭗ|ﭙ|ﭘ", "پ", res);
res = replaceRegText("ﭡ|ٺ|ٹ|ﭞ|ٿ|ټ|ﺕ|ﺗ|ﺖ|ﺘ", "ت", res);
res = replaceRegText("ﺙ|ﺛ|ﺚ|ﺜ", "ث", res);
res = replaceRegText("ﺝ|ڃ|ﺠ|ﺟ", "ج", res);
res = replaceRegText("ڃ|ﭽ|ﭼ", "چ", res);
res = replaceRegText("ﺢ|ﺤ|څ|ځ|ﺣ", "ح", res);
res = replaceRegText("ﺥ|ﺦ|ﺨ|ﺧ", "خ", res);
res = replaceRegText("ڏ|ډ|ﺪ|ﺩ", "د", res);
res = replaceRegText("ﺫ|ﺬ|ذ", "ذ", res);
res = replaceRegText("ڙ|ڗ|ڒ|ڑ|ڕ|ﺭ|ﺮ", "ر", res);
res = replaceRegText("ﺰ|ﺯ", "ز", res);
res = replaceRegText("ﮊ", "ژ", res);
res = replaceRegText("ݭ|ݜ|ﺱ|ﺲ|ښ|ﺴ|ﺳ", "س", res);
res = replaceRegText("ﺵ|ﺶ|ﺸ|ﺷ", "ش", res);
res = replaceRegText("ﺺ|ﺼ|ﺻ|ﺹ", "ص", res); //
res = replaceRegText("ﺽ|ﺾ|ﺿ|ﻀ", "ض", res);
res = replaceRegText("ﻁ|ﻂ|ﻃ|ﻄ", "ط", res);
res = replaceRegText("ﻆ|ﻇ|ﻈ", "ظ", res);
res = replaceRegText("ڠ|ﻉ|ﻊ|ﻋ|ﻌ", "ع", res);
res = replaceRegText("ﻎ|ۼ|ﻍ|ﻐ|ﻏ", "غ", res);
res = replaceRegText("ﻒ|ﻑ|ﻔ|ﻓ", "ف", res);
res = replaceRegText("ﻕ|ڤ|ﻖ|ﻗ|ﻘ", "ق", res);
res = replaceRegText("ڭ|ﻚ|ﮎ|ﻜ|ﮏ|ګ|ﻛ|ﮑ|ﮐ|ڪ|ك", "ک", res);
res = replaceRegText("ﮚ|ﮒ|ﮓ|ﮕ|ﮔ", "گ", res);
res = replaceRegText("ﻝ|ﻞ|ﻠ|ڵ|ﻟ", "ل", res); //
res = replaceRegText("ﻵ|ﻶ|ﻷ|ﻸ|ﻹ|ﻺ|ﻻ|ﻼ", "لا", res); //
res = replaceRegText("ﻡ|ﻤ|ﻢ|ﻣ", "م", res);
res = replaceRegText("ڼ|ﻦ|ﻥ|ﻨ|ﻧ", "ن", res);
res = replaceRegText("ވ|ﯙ|ۈ|ۋ|ﺆ|ۊ|ۇ|ۏ|ۅ|ۉ|ﻭ|ﻮ|ؤ", "و", res);
res = replaceRegText("|ھ||||ە|ہ", "ه", res);
res = replaceRegText("ﭛ|ﻯ|ۍ|ﻰ|ﻱ|ﻲ|ں|ﻳ|ﻴ|ﯼ|ې|ﯽ|ﯾ|ﯿ|ێ|ے|ى|ي", "ی", res);
res = replaceRegText("¬", "", res);
res = replaceRegText("•|·|●|·|・|∙|。|ⴰ", ".", res);
res = replaceRegText(",|٬|٫||،", "،", res);
res = replaceRegText("ʕ", "؟", res);
res = replaceRegText("۰|٠", "0", res);
res = replaceRegText("۱|١", "1", res);
res = replaceRegText("۲|٢", "2", res);
res = replaceRegText("۳|٣", "3", res);
res = replaceRegText("۴|٤", "4", res);
res = replaceRegText("۵", "5", res);
res = replaceRegText("۶|٦", "6", res);
res = replaceRegText("۷|٧", "7", res);
res = replaceRegText("۸|٨", "8", res);
res = replaceRegText("۹|٩", "9", res);
res = replaceRegText("²", "2", res);
res = replaceRegText("|ِ|ُ|َ|ٍ|ٌ|ً|", "", res);
// res = replaceRegText("ـ", "_", res);
res = replaceRegText("ـ", "-", res); //
// res = replaceRegText("([\u0600-\u06FF])ـ([\u0600-\u06FF])", "\1\2", res) // حذف حروف کشیده
// res = replaceRegText("([\u0600-\u06FF])ـ", "\1", res) // حذف حروف کشیده
res = res.replace(/\u200C+$/, "");
res = res.trim();
return res;
}
export function spaceCorrection(text) {
let res = text;
res = replaceRegText("^(بی|می|نمی)( )", "$1", res);
res = replaceRegText("( )(می|نمی|بی)( )", "$1$2", res);
res = replaceRegText(
"( )(هایی|ها|های|ایی|هایم|هایت|هایش|هایمان|هایتان|هایشان|ات|ان|ین|انی|بان|ام|ای|یم|ید|اید|اند|بودم|بودی|بود|بودیم|بودید|بودند|ست)( )",
"$2$3",
res
);
res = replaceRegText("( )(شده|نشده)( )", "$2", res);
res = replaceRegText(
"( )(طلبان|طلب|گرایی|گرایان|شناس|شناسی|گذاری|گذار|گذاران|شناسان|گیری|پذیری|بندی|آوری|سازی|بندی|کننده|کنندگان|گیری|پرداز|پردازی|پردازان|آمیز|سنجی|ریزی|داری|دهنده|آمیز|پذیری|پذیر|پذیران|گر|ریز|ریزی|رسانی|یاب|یابی|گانه|گانه‌ای|انگاری|گا|بند|رسانی|دهندگان|دار)( )",
"$2$3",
res
);
return res;
}
export function replaceRegText(pattern, replacement, text) {
const regex = new RegExp(pattern, "g");
return text.replace(regex, replacement);
}
export function generateUID() {
// I generate the UID from two parts here
// to ensure the random number provide enough bits.
var firstPart = (Math.random() * 46656) | 0;
var secondPart = (Math.random() * 46656) | 0;
firstPart = ("000" + firstPart.toString(36)).slice(-3);
secondPart = ("000" + secondPart.toString(36)).slice(-3);
return firstPart + secondPart; // eg : 9c8yxr
}
function myEncodeQuery(text) {
if (!text || text == "") return "";
//text = JSON.stringify(text);
let ch1 = encodeURIComponent("#");
let ch3 = encodeURIComponent("\\");
let ch4 = encodeURIComponent("&");
text = text.replaceAll("#", ch1);
text = text.replaceAll("&", ch4);
text = text.replaceAll("/", "\\");
text = text.replaceAll("\\", ch3);
// با تبدیل نقطه مشکل نشانی درخواست در بک حل نشد
//text = text.replaceAll(".", '%2E');
return text;
}
export function cleanTextUnpermittedChars(text) {
if (!text) return "";
text = text.replaceAll("([0x0000-0x001F]|(0x007F))", "");
return text;
}
// utils/tree.js
export function findNodeWithPath(nodes, targetId, path = []) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const currentPath = [...path, i];
// ✅ Adjust this condition based on how you identify a node
if (node.id === targetId) {
return { node, path: currentPath };
}
// Recurse into children if they exist
if (node.children && node.children.length > 0) {
const result = findNodeWithPath(node.children, targetId, currentPath);
if (result) return result;
}
}
return null; // Not found
}
export function updateNodeByPath(nodes, path, newValue) {
let current = nodes;
// Navigate to the parent of the target node
for (let i = 0; i < path.length - 1; i++) {
current = current[path[i]].children;
}
// Update the target node
const targetIndex = path[path.length - 1];
current[targetIndex] = { ...current[targetIndex], ...newValue };
}
//////////////////////////////////////////
// export { wait, handleErrors }

View File

@ -13,6 +13,7 @@
:is="currentComponent"
v-if="currentComponent"
:activeTabKey="activeTabKey"
:listConflicts="listConflicts"
/>
</div>
</template>
@ -26,11 +27,13 @@ import { ref, onMounted } from "vue";
import tabBarData from "@/json/tab-bar/data-entry/dataEntry.json";
import { defineAsyncComponent } from "vue";
const route = useRoute();
const { $http: httpService } = useNuxtApp();
// Stateهای Header
const isSidebarCollapsed = ref(false);
const unreadNotifications = ref(5);
const headerTabs = ref([]);
const listConflicts = ref([]);
headerTabs.value = tabBarData.tabs;
const activeTabKey = ref(tabBarData.tabs[0]?.key || "");
// const contentSchema = computed(() => ({
@ -79,7 +82,58 @@ headerSchema.value = {
breadcrumb: true,
logo: false,
};
const pagination = ref({
total: 0,
page: 1,
limit: 10,
});
const getListConflict = async (textSearch = "", filterExtended = "") => {
const offset = (pagination.value.page - 1) * pagination.value.limit;
const limit = pagination.value.limit;
let tabFilter = "";
let mode_url = "elp";
let index_key = "qaconflict";
// if (props.activeTabKey === "mirbagheri") {
// tabFilter = "&f_au=استاد سید محمد مهدی میرباقری";
// mode_url = "elp_db";
// } else if (props.activeTabKey === "monir") {
// tabFilter = "&f_au=سید منیر الدین حسینی الهاشمی(ره)";
// mode_url = "elp_db";
// }
// let allFilters = tabFilter + filterPanel.value;
const request = utilGetSearchRequest(
index_key,
textSearch,
"normal",
mode_url,
tabFilter,
filterExtended,
offset,
limit,
);
request.payload_full["search_fields"] = [
"branch",
"title",
"subtitle",
"author",
];
try {
const res = await httpService.postRequest(
request.url,
request.payload_full,
);
console.log("res", res);
listConflicts.value = res;
} catch (err) {
console.error("خطا در دریافت داده:", err);
}
};
onMounted(() => {
getListConflict();
activeTabKey.value = headerTabs.value[0]?.id || "";
});
</script>

View File

@ -5,7 +5,7 @@ export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
modules: ["@nuxt/ui", "@pinia/nuxt", "@nuxt/icon"],
css: ["~/assets/css/main.css"],
css: ["@/assets/css/main.css"],
imports: {
dirs: ["stores", "composables", "apis"],
},
@ -42,7 +42,7 @@ export default defineNuxtConfig({
// پوشه‌های داخل app/
"@/components": fileURLToPath(new URL("./app/components", import.meta.url)),
"@/composables": fileURLToPath(
new URL("./app/composables", import.meta.url)
new URL("./app/composables", import.meta.url),
),
"@/layouts": fileURLToPath(new URL("./app/layouts", import.meta.url)),
"@/pages": fileURLToPath(new URL("./app/pages", import.meta.url)),
@ -66,7 +66,7 @@ export default defineNuxtConfig({
// .nuxt (داخل rootDir)
"@/build": fileURLToPath(new URL("./.nuxt", import.meta.url)),
"@/internal/nuxt/paths": fileURLToPath(
new URL("./.nuxt/paths.mjs", import.meta.url)
new URL("./.nuxt/paths.mjs", import.meta.url),
),
// shared (اگر وجود داشته باشد — در rootDir)

23
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
"@tiptap/extension-color": "^3.19.0",
"@tiptap/extension-details": "^3.19.0",
"@tiptap/extension-drag-handle": "^3.19.0",
"@tiptap/extension-horizontal-rule": "^3.19.0",
"@tiptap/extension-link": "^3.19.0",
@ -23,6 +24,7 @@
"@tiptap/starter-kit": "^3.18.0",
"@tiptap/vue-3": "^3.18.0",
"@vueuse/integrations": "^14.1.0",
"export-from-json": "^1.7.4",
"lodash-es": "^4.17.22",
"nuxt": "^4.2.1",
"pinia": "^3.0.4",
@ -5476,6 +5478,21 @@
"@tiptap/extension-text-style": "^3.19.0"
}
},
"node_modules/@tiptap/extension-details": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-details/-/extension-details-3.19.0.tgz",
"integrity": "sha512-BUc9N8UVl/orzXoDSh9YCB+G1csNDAKm4EeKAQpGotbgv32nnTPKv50BvPx+a5pZfVQHaiJlb98Xmi7dMZs1Ug==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.19.0",
"@tiptap/extension-text-style": "^3.19.0",
"@tiptap/pm": "^3.19.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.18.0",
"resolved": "https://mirror-npm.runflare.com/@tiptap/extension-document/-/extension-document-3.18.0.tgz",
@ -8616,6 +8633,12 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/export-from-json": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/export-from-json/-/export-from-json-1.7.4.tgz",
"integrity": "sha512-FjmpluvZS2PTYyhkoMfQoyEJMfe2bfAyNpa5Apa6C9n7SWUWyJkG/VFnzERuj3q9Jjo3iwBjwVsDQ7Z7sczthA==",
"license": "MIT"
},
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://mirror-npm.runflare.com/exsolve/-/exsolve-1.0.8.tgz",

View File

@ -14,6 +14,7 @@
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
"@tiptap/extension-color": "^3.19.0",
"@tiptap/extension-details": "^3.19.0",
"@tiptap/extension-drag-handle": "^3.19.0",
"@tiptap/extension-horizontal-rule": "^3.19.0",
"@tiptap/extension-link": "^3.19.0",
@ -25,6 +26,7 @@
"@tiptap/starter-kit": "^3.18.0",
"@tiptap/vue-3": "^3.18.0",
"@vueuse/integrations": "^14.1.0",
"export-from-json": "^1.7.4",
"lodash-es": "^4.17.22",
"nuxt": "^4.2.1",
"pinia": "^3.0.4",