Replace the landing with the artifact design and rebuild the admin in Ant Design.
Adds consultation leads, section editors, and the one-time content reset for this release. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+3
-1
@@ -4,7 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>Montech Admin</title>
|
||||
<meta name="theme-color" content="#0062f5" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<title>پنل مدیریت منتک</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -10,8 +10,12 @@
|
||||
"lint": "echo \"adm lint placeholder\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@fontsource/vazirmatn": "^5.3.0",
|
||||
"@montech/shared": "*",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.8.4",
|
||||
"dayjs": "^1.11.23",
|
||||
"pinia": "^3.0.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
@@ -19,6 +23,7 @@
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"typescript": "^5.8.2",
|
||||
"unplugin-vue-components": "^0.28.0",
|
||||
"vite": "^6.2.2",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="44" y2="44" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0062f5"/>
|
||||
<stop offset="0.5" stop-color="#007dfc"/>
|
||||
<stop offset="1" stop-color="#38bdf8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="44" height="44" rx="16" fill="url(#g)"/>
|
||||
<g transform="translate(8.25 8.25) scale(0.859375)">
|
||||
<path d="M6 22L12 8L16 15L20 8L26 22" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="16" cy="22" r="2.2" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 642 B |
+23
-1
@@ -1,7 +1,29 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
<a-config-provider :locale="faIR" direction="rtl" :theme="theme">
|
||||
<a-app>
|
||||
<RouterView />
|
||||
</a-app>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from "vue-router";
|
||||
import faIR from "ant-design-vue/es/locale/fa_IR";
|
||||
import type { ThemeConfig } from "ant-design-vue/es/config-provider/context";
|
||||
import "dayjs/locale/fa";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
dayjs.locale("fa");
|
||||
|
||||
const theme: ThemeConfig = {
|
||||
token: {
|
||||
colorPrimary: "#0062f5",
|
||||
colorInfo: "#0062f5",
|
||||
borderRadius: 10,
|
||||
fontFamily: "Vazirmatn, Tahoma, sans-serif",
|
||||
},
|
||||
components: {
|
||||
Layout: { colorBgHeader: "#ffffff", colorBgBody: "#f5f7fb" },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
Vendored
+58
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AAlert: typeof import('ant-design-vue/es')['Alert']
|
||||
AApp: typeof import('ant-design-vue/es')['App']
|
||||
ABadge: typeof import('ant-design-vue/es')['Badge']
|
||||
AButton: typeof import('ant-design-vue/es')['Button']
|
||||
ACard: typeof import('ant-design-vue/es')['Card']
|
||||
ACardMeta: typeof import('ant-design-vue/es')['CardMeta']
|
||||
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
|
||||
ACol: typeof import('ant-design-vue/es')['Col']
|
||||
ACollapse: typeof import('ant-design-vue/es')['Collapse']
|
||||
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
|
||||
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
|
||||
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
|
||||
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
|
||||
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
|
||||
AForm: typeof import('ant-design-vue/es')['Form']
|
||||
AFormItem: typeof import('ant-design-vue/es')['FormItem']
|
||||
AImage: typeof import('ant-design-vue/es')['Image']
|
||||
AInput: typeof import('ant-design-vue/es')['Input']
|
||||
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
|
||||
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
|
||||
ALayout: typeof import('ant-design-vue/es')['Layout']
|
||||
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
|
||||
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
|
||||
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
|
||||
AMenu: typeof import('ant-design-vue/es')['Menu']
|
||||
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
|
||||
AModal: typeof import('ant-design-vue/es')['Modal']
|
||||
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
|
||||
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
|
||||
AResult: typeof import('ant-design-vue/es')['Result']
|
||||
ARow: typeof import('ant-design-vue/es')['Row']
|
||||
ASegmented: typeof import('ant-design-vue/es')['Segmented']
|
||||
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
|
||||
ASpace: typeof import('ant-design-vue/es')['Space']
|
||||
ASpin: typeof import('ant-design-vue/es')['Spin']
|
||||
AStatistic: typeof import('ant-design-vue/es')['Statistic']
|
||||
ASwitch: typeof import('ant-design-vue/es')['Switch']
|
||||
ATable: typeof import('ant-design-vue/es')['Table']
|
||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||
ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
|
||||
AUpload: typeof import('ant-design-vue/es')['Upload']
|
||||
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,58 @@
|
||||
<template>
|
||||
<div class="field">
|
||||
<label v-if="label" class="label">{{ label }}</label>
|
||||
<div class="row" style="gap: 0.75rem; align-items: flex-start">
|
||||
<div style="flex: 1">
|
||||
<input v-model="model" class="input" dir="ltr" :placeholder="placeholder" />
|
||||
<div class="row" style="margin-top: 0.5rem">
|
||||
<input type="file" accept="image/*" @change="onFile" />
|
||||
<button type="button" class="btn ghost" :disabled="!file || uploading" @click="upload">
|
||||
{{ uploading ? "در حال آپلود..." : "آپلود تصویر" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="hint" class="muted" style="margin-top: 0.35rem">{{ hint }}</p>
|
||||
<p v-if="err" class="muted" style="color: var(--danger); margin-top: 0.35rem">{{ err }}</p>
|
||||
</div>
|
||||
<img
|
||||
v-if="model"
|
||||
:src="model"
|
||||
alt=""
|
||||
style="width: 72px; height: 72px; object-fit: cover; border-radius: 8px; border: 1px solid var(--border); background: #0c0e12"
|
||||
/>
|
||||
</div>
|
||||
<div class="image-field">
|
||||
<a-input :id="id" v-model:value="model" class="ltr" :placeholder="placeholder || '/api/uploads/...'" allow-clear />
|
||||
<a-upload :show-upload-list="false" accept="image/*" :custom-request="upload">
|
||||
<a-button :loading="uploading">
|
||||
<template #icon><UploadOutlined /></template>
|
||||
آپلود
|
||||
</a-button>
|
||||
</a-upload>
|
||||
<a-image v-if="model" :src="model" :width="40" :height="40" class="preview" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { api } from "../lib/api";
|
||||
import { message } from "ant-design-vue";
|
||||
import type { UploadRequestOption } from "ant-design-vue/es/vc-upload/interface";
|
||||
import { UploadOutlined } from "@ant-design/icons-vue";
|
||||
import { api, apiErrorMessage } from "../lib/api";
|
||||
|
||||
const model = defineModel<string>({ default: "" });
|
||||
defineProps<{
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}>();
|
||||
defineProps<{ placeholder?: string; id?: string }>();
|
||||
|
||||
const file = ref<File | null>(null);
|
||||
const uploading = ref(false);
|
||||
const err = ref("");
|
||||
|
||||
function onFile(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
file.value = input.files?.[0] || null;
|
||||
err.value = "";
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
if (!file.value) return;
|
||||
async function upload(options: UploadRequestOption) {
|
||||
uploading.value = true;
|
||||
err.value = "";
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file.value);
|
||||
const { data } = await api.post("/admin/media/upload", fd);
|
||||
fd.append("file", options.file as Blob);
|
||||
const { data } = await api.post<{ url: string }>("/admin/media/upload", fd);
|
||||
model.value = data.url;
|
||||
file.value = null;
|
||||
} catch {
|
||||
err.value = "آپلود ناموفق بود.";
|
||||
options.onSuccess?.(data);
|
||||
message.success("تصویر آپلود شد.");
|
||||
} catch (e) {
|
||||
options.onError?.(e as Error);
|
||||
message.error(apiErrorMessage(e, "آپلود ناموفق بود."));
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.image-field :deep(.ant-input-affix-wrapper) {
|
||||
flex: 1;
|
||||
}
|
||||
.preview :deep(img),
|
||||
.image-field :deep(.ant-image-img) {
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<a-row :gutter="16">
|
||||
<a-col v-for="field in fields" :key="field.key" :xs="24" :md="isBlock(field) ? 24 : field.span ?? 24">
|
||||
<a-form-item
|
||||
v-if="field.kind === 'text' || field.kind === 'textarea' || field.kind === 'image' || field.kind === 'number' || field.kind === 'select'"
|
||||
:label="field.label"
|
||||
:html-for="idOf(field.key)"
|
||||
:extra="field.hint"
|
||||
:required="'required' in field && field.required"
|
||||
:validate-status="isMissing(field) ? 'error' : undefined"
|
||||
:help="isMissing(field) ? 'این فیلد الزامی است.' : undefined"
|
||||
>
|
||||
<a-input v-if="field.kind === 'text'" :id="idOf(field.key)" v-model:value="model[field.key]" :class="{ ltr: field.ltr }" />
|
||||
<a-textarea v-else-if="field.kind === 'textarea'" :id="idOf(field.key)" v-model:value="model[field.key]" :auto-size="{ minRows: 2, maxRows: 6 }" />
|
||||
<ImageField v-else-if="field.kind === 'image'" :id="idOf(field.key)" v-model="model[field.key]" />
|
||||
<a-input-number v-else-if="field.kind === 'number'" :id="idOf(field.key)" v-model:value="model[field.key]" :min="field.min" :max="field.max" style="width: 100%" />
|
||||
<a-select v-else-if="field.kind === 'select'" :id="idOf(field.key)" v-model:value="model[field.key]" :options="field.options" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-else-if="field.kind === 'strings'" :label="field.label" :html-for="idOf(field.key, 0)" :extra="field.hint">
|
||||
<div v-for="(_, i) in asArray(field.key)" :key="i" class="string-row">
|
||||
<ImageField v-if="field.item === 'image'" :id="idOf(field.key, i)" v-model="asArray(field.key)[i]" />
|
||||
<a-input
|
||||
v-else
|
||||
:id="idOf(field.key, i)"
|
||||
v-model:value="asArray(field.key)[i]"
|
||||
:aria-label="`${field.label} — ${field.itemLabel} ${toFa(i + 1)}`"
|
||||
:placeholder="`${field.itemLabel} ${toFa(i + 1)}`"
|
||||
/>
|
||||
<a-button v-if="!field.fixed" danger type="text" aria-label="حذف" title="حذف" @click="asArray(field.key).splice(i, 1)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="!field.fixed && (!field.max || asArray(field.key).length < field.max)"
|
||||
type="dashed"
|
||||
block
|
||||
@click="asArray(field.key).push('')"
|
||||
>
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن {{ field.itemLabel }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<div v-else-if="field.kind === 'list'" class="block">
|
||||
<div class="block-head">
|
||||
<strong>{{ field.label }}</strong>
|
||||
<a-typography-text v-if="field.hint" type="secondary">{{ field.hint }}</a-typography-text>
|
||||
</div>
|
||||
<a-card v-for="(item, i) in asList(field.key)" :key="i" size="small" class="item-card">
|
||||
<template #title>{{ field.itemLabel }} {{ toFa(i + 1) }}</template>
|
||||
<template #extra>
|
||||
<a-space :size="4">
|
||||
<a-button size="small" type="text" :disabled="i === 0" title="بالا" aria-label="انتقال به بالا" @click="move(field.key, i, -1)">
|
||||
<template #icon><ArrowUpOutlined /></template>
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
type="text"
|
||||
:disabled="i === asList(field.key).length - 1"
|
||||
title="پایین"
|
||||
aria-label="انتقال به پایین"
|
||||
@click="move(field.key, i, 1)"
|
||||
>
|
||||
<template #icon><ArrowDownOutlined /></template>
|
||||
</a-button>
|
||||
<a-popconfirm title="حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="asList(field.key).splice(i, 1)">
|
||||
<a-button size="small" type="text" danger title="حذف" :aria-label="`حذف ${field.itemLabel} ${toFa(i + 1)}`">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
<SchemaFields :fields="field.fields" :model="item" :show-errors="showErrors" :id-prefix="idOf(field.key, i)" />
|
||||
</a-card>
|
||||
<a-button v-if="!field.max || asList(field.key).length < field.max" type="dashed" block @click="asList(field.key).push(field.newItem())">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن {{ field.itemLabel }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-card v-else-if="field.kind === 'group'" size="small" :title="field.label" class="block">
|
||||
<SchemaFields :fields="field.fields" :model="asObject(field.key)" :show-errors="showErrors" :id-prefix="idOf(field.key)" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from "@ant-design/icons-vue";
|
||||
import type { Field } from "../sections/schema";
|
||||
import ImageField from "./ImageField.vue";
|
||||
|
||||
type Model = Record<string, any>;
|
||||
|
||||
const props = withDefaults(defineProps<{ fields: Field[]; model: Model; showErrors?: boolean; idPrefix?: string }>(), {
|
||||
showErrors: false,
|
||||
idPrefix: "sf",
|
||||
});
|
||||
|
||||
const toFa = (n: number) => n.toLocaleString("fa-IR");
|
||||
|
||||
function idOf(key: string, index?: number) {
|
||||
return index === undefined ? `${props.idPrefix}-${key}` : `${props.idPrefix}-${key}-${index}`;
|
||||
}
|
||||
|
||||
function isBlock(field: Field) {
|
||||
return field.kind === "list" || field.kind === "group" || field.kind === "strings";
|
||||
}
|
||||
|
||||
function isMissing(field: Field) {
|
||||
if (!props.showErrors || !("required" in field) || !field.required) return false;
|
||||
const v = props.model[field.key];
|
||||
return typeof v !== "string" || !v.trim();
|
||||
}
|
||||
|
||||
function asArray(key: string): string[] {
|
||||
if (!Array.isArray(props.model[key])) props.model[key] = [];
|
||||
return props.model[key];
|
||||
}
|
||||
|
||||
function asList(key: string): Model[] {
|
||||
if (!Array.isArray(props.model[key])) props.model[key] = [];
|
||||
return props.model[key];
|
||||
}
|
||||
|
||||
function asObject(key: string): Model {
|
||||
const v = props.model[key];
|
||||
if (!v || typeof v !== "object" || Array.isArray(v)) props.model[key] = {};
|
||||
return props.model[key];
|
||||
}
|
||||
|
||||
function move(key: string, i: number, delta: number) {
|
||||
const list = asList(key);
|
||||
const j = i + delta;
|
||||
if (j < 0 || j >= list.length) return;
|
||||
[list[i], list[j]] = [list[j], list[i]];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.block {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.block-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.item-card {
|
||||
margin-bottom: 10px;
|
||||
background: #fafbfd;
|
||||
}
|
||||
.string-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.string-row > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ref } from "vue";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
const newCount = ref(0);
|
||||
|
||||
export function useLeadsCount() {
|
||||
async function refresh() {
|
||||
try {
|
||||
const { data } = await api.get<{ newCount: number }>("/admin/leads", { params: { pageSize: 1 } });
|
||||
newCount.value = data.newCount ?? 0;
|
||||
} catch {
|
||||
/* the badge is non-critical */
|
||||
}
|
||||
}
|
||||
return { newCount, refresh };
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/* IRANSans from landing assets (shared path in monorepo) */
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_UltraLight.woff2") format("woff2"),
|
||||
url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_UltraLight.woff") format("woff");
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Light.woff2") format("woff2"),
|
||||
url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Light.woff") format("woff");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum).woff2") format("woff2"),
|
||||
url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum).woff") format("woff");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2") format("woff2"),
|
||||
url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Medium.woff") format("woff");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2") format("woff2"),
|
||||
url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Medium.woff") format("woff");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Bold.woff2") format("woff2"),
|
||||
url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Bold.woff") format("woff");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2") format("woff2"),
|
||||
url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Black.woff") format("woff");
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IRANSans";
|
||||
src: url("../../landing/assets/fonts/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2") format("woff2"),
|
||||
url("../../landing/assets/fonts/iranSans/woff/IRANSansWeb(FaNum)_Black.woff") format("woff");
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -1,67 +1,163 @@
|
||||
<template>
|
||||
<div class="shell">
|
||||
<aside class="side">
|
||||
<div class="brand">Montech <span>/adm</span></div>
|
||||
<nav>
|
||||
<RouterLink to="/">داشبورد</RouterLink>
|
||||
<RouterLink to="/settings">تنظیمات سایت</RouterLink>
|
||||
<RouterLink to="/sections">محتوای لندینگ</RouterLink>
|
||||
<RouterLink to="/blog">بلاگ</RouterLink>
|
||||
<RouterLink to="/gallery">گالری</RouterLink>
|
||||
<RouterLink to="/seo">سئو صفحات</RouterLink>
|
||||
<RouterLink to="/media">رسانه</RouterLink>
|
||||
</nav>
|
||||
<button class="btn ghost" @click="onLogout">خروج</button>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
<a-layout class="shell">
|
||||
<a-layout-sider
|
||||
v-model:collapsed="collapsed"
|
||||
:width="232"
|
||||
breakpoint="lg"
|
||||
collapsible
|
||||
:collapsed-width="64"
|
||||
theme="light"
|
||||
class="side"
|
||||
>
|
||||
<RouterLink to="/" class="brand">
|
||||
<img src="/logo.svg" alt="" width="32" height="32" />
|
||||
<span v-if="!collapsed">منتک <small>پنل مدیریت</small></span>
|
||||
</RouterLink>
|
||||
<a-menu :selected-keys="selectedKeys" mode="inline" :items="menuItems" @click="onMenu" />
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout>
|
||||
<a-layout-header class="header">
|
||||
<a-space :size="12">
|
||||
<a-badge :count="leadsBadge" :overflow-count="99">
|
||||
<a-button type="text" @click="router.push('/leads')">
|
||||
<template #icon><InboxOutlined /></template>
|
||||
درخواستها
|
||||
</a-button>
|
||||
</a-badge>
|
||||
<a-button href="/" target="_blank" type="text">
|
||||
<template #icon><GlobalOutlined /></template>
|
||||
مشاهده سایت
|
||||
</a-button>
|
||||
</a-space>
|
||||
<a-dropdown placement="bottomLeft">
|
||||
<a-button type="text">
|
||||
<template #icon><UserOutlined /></template>
|
||||
{{ auth.admin?.name || "ادمین" }}
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="onLogout">
|
||||
<a-menu-item key="logout">
|
||||
<LogoutOutlined />
|
||||
خروج
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-layout-header>
|
||||
<a-layout-content class="main">
|
||||
<RouterView />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { computed, h, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import type { MenuProps } from "ant-design-vue";
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
DashboardOutlined,
|
||||
FileTextOutlined,
|
||||
GlobalOutlined,
|
||||
InboxOutlined,
|
||||
LogoutOutlined,
|
||||
PictureOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
CloudUploadOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { useLeadsCount } from "../composables/useLeadsCount";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const collapsed = ref(false);
|
||||
const { newCount, refresh } = useLeadsCount();
|
||||
|
||||
onMounted(() => {
|
||||
auth.fetchMe().catch(() => auth.logout());
|
||||
const NAV = [
|
||||
{ key: "/", label: "داشبورد", icon: DashboardOutlined },
|
||||
{ key: "/leads", label: "درخواستهای مشاوره", icon: InboxOutlined },
|
||||
{ key: "/sections", label: "محتوای لندینگ", icon: AppstoreOutlined },
|
||||
{ key: "/settings", label: "تنظیمات سایت", icon: SettingOutlined },
|
||||
{ key: "/blog", label: "بلاگ", icon: FileTextOutlined },
|
||||
{ key: "/gallery", label: "گالری", icon: PictureOutlined },
|
||||
{ key: "/seo", label: "سئو صفحات", icon: SearchOutlined },
|
||||
{ key: "/media", label: "رسانه", icon: CloudUploadOutlined },
|
||||
];
|
||||
|
||||
const menuItems = computed<MenuProps["items"]>(() =>
|
||||
NAV.map((n) => ({ key: n.key, label: n.label, icon: () => h(n.icon) })),
|
||||
);
|
||||
|
||||
const selectedKeys = computed(() => {
|
||||
const match = NAV.filter((n) => n.key !== "/" && route.path.startsWith(n.key));
|
||||
return [match[0]?.key ?? "/"];
|
||||
});
|
||||
|
||||
const leadsBadge = computed(() => newCount.value);
|
||||
|
||||
const onMenu: MenuProps["onClick"] = ({ key }) => {
|
||||
router.push(String(key));
|
||||
};
|
||||
|
||||
function onLogout() {
|
||||
auth.logout();
|
||||
router.push({ name: "login" });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
auth.fetchMe().catch(() => undefined);
|
||||
refresh();
|
||||
});
|
||||
|
||||
watch(() => route.path, refresh);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shell { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.side {
|
||||
border-left: 1px solid var(--border);
|
||||
background: #12151b;
|
||||
padding: 1.25rem;
|
||||
border-inline-start: 1px solid #f0f0f0;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 64px;
|
||||
padding: 0 16px;
|
||||
color: #0f172a;
|
||||
font-weight: 800;
|
||||
font-size: 17px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.brand { font-weight: 700; font-size: 1.1rem; }
|
||||
.brand span { color: var(--accent); }
|
||||
nav { display: flex; flex-direction: column; gap: 0.35rem; flex: 1; }
|
||||
nav a {
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
.brand small {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
nav a.router-link-active {
|
||||
background: #1d2430;
|
||||
color: var(--text);
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
line-height: normal;
|
||||
}
|
||||
.main { padding: 1.5rem; }
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.side { border-left: 0; border-bottom: 1px solid var(--border); }
|
||||
.main {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.main {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+20
-1
@@ -1,4 +1,4 @@
|
||||
import axios from "axios";
|
||||
import axios, { isAxiosError } from "axios";
|
||||
|
||||
const TOKEN_KEY = "montech_adm_token";
|
||||
|
||||
@@ -12,6 +12,18 @@ api.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(error) => {
|
||||
if (isAxiosError(error) && error.response?.status === 401 && !error.config?.url?.startsWith("/auth/login")) {
|
||||
setToken(null);
|
||||
const redirect = encodeURIComponent(window.location.pathname.replace(/^\/adm/, "") || "/");
|
||||
window.location.assign(`/adm/login?redirect=${redirect}`);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export function setToken(token: string | null) {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
@@ -20,3 +32,10 @@ export function setToken(token: string | null) {
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function apiErrorMessage(error: unknown, fallback = "خطا در ارتباط با سرور.") {
|
||||
if (!isAxiosError(error)) return fallback;
|
||||
const detail = (error.response?.data as { message?: string | string[] } | undefined)?.message;
|
||||
if (Array.isArray(detail)) return detail.join("، ");
|
||||
return detail || fallback;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import "@fontsource/vazirmatn/400.css";
|
||||
import "@fontsource/vazirmatn/500.css";
|
||||
import "@fontsource/vazirmatn/700.css";
|
||||
import "ant-design-vue/dist/reset.css";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./styles.css";
|
||||
|
||||
@@ -10,6 +10,7 @@ const router = createRouter({
|
||||
component: () => import("../layouts/AdminLayout.vue"),
|
||||
children: [
|
||||
{ path: "", name: "dashboard", component: () => import("../views/DashboardView.vue") },
|
||||
{ path: "leads", name: "leads", component: () => import("../views/LeadsView.vue") },
|
||||
{ path: "settings", name: "settings", component: () => import("../views/SettingsView.vue") },
|
||||
{ path: "sections", name: "sections", component: () => import("../views/SectionsView.vue") },
|
||||
{ path: "sections/:key", name: "section-edit", component: () => import("../views/SectionEditView.vue") },
|
||||
@@ -21,6 +22,7 @@ const router = createRouter({
|
||||
{ path: "media", name: "media", component: () => import("../views/MediaView.vue") },
|
||||
],
|
||||
},
|
||||
{ path: "/:pathMatch(.*)*", redirect: "/" },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
METRIC_ICONS,
|
||||
SOLUTION_ACCENTS,
|
||||
SOLUTION_ICONS,
|
||||
type SectionKey,
|
||||
} from "@montech/shared";
|
||||
|
||||
export interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface BaseField {
|
||||
key: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
/** Grid span out of 24 on desktop. */
|
||||
span?: number;
|
||||
}
|
||||
|
||||
export type Field =
|
||||
| (BaseField & { kind: "text" | "textarea" | "image"; ltr?: boolean; required?: boolean })
|
||||
| (BaseField & { kind: "number"; min?: number; max?: number })
|
||||
| (BaseField & { kind: "select"; options: Option[] })
|
||||
| (BaseField & { kind: "strings"; itemLabel: string; item?: "text" | "image"; fixed?: number; max?: number })
|
||||
| (BaseField & { kind: "list"; itemLabel: string; fields: Field[]; newItem: () => Record<string, unknown>; max?: number })
|
||||
| (BaseField & { kind: "group"; fields: Field[] });
|
||||
|
||||
export interface SectionSchema {
|
||||
description: string;
|
||||
fields: Field[];
|
||||
}
|
||||
|
||||
const linkFields: Field[] = [
|
||||
{ kind: "text", key: "label", label: "متن", span: 12, required: true },
|
||||
{ kind: "text", key: "href", label: "لینک", span: 12, ltr: true, hint: "مثلاً #solutions یا /blog" },
|
||||
];
|
||||
|
||||
const newLink = () => ({ label: "", href: "#" });
|
||||
|
||||
const opt = (values: readonly string[], labels: Record<string, string>): Option[] =>
|
||||
values.map((v) => ({ value: v, label: labels[v] ?? v }));
|
||||
|
||||
export const SECTION_SCHEMAS: Record<SectionKey, SectionSchema> = {
|
||||
header: {
|
||||
description: "لوگو، منوی بالای صفحه و دکمه مشاوره.",
|
||||
fields: [
|
||||
{ kind: "text", key: "brandTitle", label: "نام برند", span: 12, required: true },
|
||||
{ kind: "text", key: "brandSubtitle", label: "زیرعنوان برند", span: 12, ltr: true },
|
||||
{ kind: "text", key: "ctaLabel", label: "متن دکمه مشاوره", span: 12 },
|
||||
{
|
||||
kind: "list",
|
||||
key: "nav",
|
||||
label: "آیتمهای منو",
|
||||
hint: "به ترتیب خواندن از راست به چپ.",
|
||||
itemLabel: "آیتم",
|
||||
fields: linkFields,
|
||||
newItem: newLink,
|
||||
max: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
hero: {
|
||||
description: "تیتر اصلی، جستجوی هوشمند، ماکاپ لپتاپ/موبایل و پنجره پیشنهاد هوشمند.",
|
||||
fields: [
|
||||
{ kind: "text", key: "eyebrow", label: "برچسب بالای تیتر", span: 12 },
|
||||
{ kind: "text", key: "titleLine1", label: "خط اول تیتر", span: 12, required: true },
|
||||
{ kind: "text", key: "titleHighlight", label: "خط رنگی تیتر", span: 12, required: true },
|
||||
{ kind: "textarea", key: "subtitle", label: "توضیح زیر تیتر" },
|
||||
{ kind: "text", key: "searchPlaceholder", label: "متن راهنمای جستجو", span: 12 },
|
||||
{ kind: "text", key: "searchDefaultQuery", label: "پرسوجوی پیشفرض (جستجوی خالی)", span: 12 },
|
||||
{ kind: "text", key: "suggestionsLabel", label: "عنوان پیشنهادها", span: 12 },
|
||||
{
|
||||
kind: "list",
|
||||
key: "suggestions",
|
||||
label: "پیشنهادهای پرطرفدار",
|
||||
itemLabel: "پیشنهاد",
|
||||
max: 6,
|
||||
fields: [
|
||||
{ kind: "text", key: "label", label: "متن دکمه", span: 8, required: true },
|
||||
{ kind: "text", key: "query", label: "پرسوجوی ارسالی", span: 16 },
|
||||
],
|
||||
newItem: () => ({ label: "", query: "" }),
|
||||
},
|
||||
{
|
||||
kind: "group",
|
||||
key: "mockup",
|
||||
label: "ماکاپ لپتاپ و موبایل",
|
||||
fields: [
|
||||
{ kind: "text", key: "domain", label: "دامنه نوار آدرس", span: 12, ltr: true },
|
||||
{ kind: "text", key: "version", label: "نسخه", span: 12 },
|
||||
{ kind: "text", key: "salesValue", label: "مبلغ فروش", span: 12 },
|
||||
{ kind: "text", key: "salesGrowth", label: "رشد فروش", span: 12 },
|
||||
{ kind: "text", key: "customersValue", label: "تعداد مشتریان", span: 12 },
|
||||
{ kind: "text", key: "satisfaction", label: "شاخص رضایت", span: 12 },
|
||||
{ kind: "text", key: "chartDefaultTooltip", label: "برچسب پیشفرض نمودار", span: 12 },
|
||||
{ kind: "number", key: "goldBarCount", label: "موجودی شمش (موبایل)", span: 12, min: 0, max: 9999 },
|
||||
{ kind: "strings", key: "chartTooltips", label: "برچسب نقاط نمودار", itemLabel: "نقطه", fixed: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "group",
|
||||
key: "ai",
|
||||
label: "پنجره پیشنهاد هوشمند",
|
||||
fields: [
|
||||
{ kind: "text", key: "title", label: "عنوان پنجره", span: 12 },
|
||||
{ kind: "text", key: "queryLabel", label: "برچسب پرسوجو", span: 12 },
|
||||
{ kind: "text", key: "fallbackQuery", label: "پرسوجوی جایگزین", span: 12 },
|
||||
{ kind: "text", key: "packageLabel", label: "برچسب بسته", span: 12 },
|
||||
{ kind: "text", key: "packageTitle", label: "عنوان بسته", span: 12 },
|
||||
{ kind: "text", key: "matchBadge", label: "نشان تطابق", span: 12 },
|
||||
{ kind: "strings", key: "items", label: "موارد بسته", itemLabel: "مورد", max: 6 },
|
||||
{ kind: "text", key: "primaryCta", label: "دکمه ثبت مشاوره", span: 12 },
|
||||
{ kind: "text", key: "closeLabel", label: "دکمه بستن", span: 12 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
metrics: {
|
||||
description: "نوار سهتایی آمار زیر هیرو.",
|
||||
fields: [
|
||||
{
|
||||
kind: "list",
|
||||
key: "items",
|
||||
label: "آمار",
|
||||
itemLabel: "آمار",
|
||||
max: 3,
|
||||
fields: [
|
||||
{ kind: "select", key: "icon", label: "آیکون", span: 6, options: opt(METRIC_ICONS, { gear: "چرخدنده", users: "کاربران", box: "جعبه" }) },
|
||||
{ kind: "text", key: "value", label: "مقدار", span: 6, required: true },
|
||||
{ kind: "text", key: "label", label: "برچسب", span: 6 },
|
||||
{ kind: "select", key: "emphasis", label: "نمایش", span: 6, options: [{ value: "number", label: "عدد بزرگ" }, { value: "text", label: "متن پررنگ" }] },
|
||||
],
|
||||
newItem: () => ({ icon: "users", value: "", label: "", emphasis: "number" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
features: {
|
||||
description: "بخش «از نسخه پایه تا راهکار اختصاصی» با کارتهای سهبعدی.",
|
||||
fields: [
|
||||
{ kind: "text", key: "eyebrow", label: "برچسب", span: 12 },
|
||||
{ kind: "text", key: "titleLine1", label: "خط اول تیتر", span: 12 },
|
||||
{ kind: "text", key: "titleLine2", label: "خط دوم تیتر (رنگی)", span: 12 },
|
||||
{ kind: "textarea", key: "body", label: "توضیح" },
|
||||
{ kind: "text", key: "ctaLabel", label: "متن دکمه", span: 12 },
|
||||
{ kind: "text", key: "ctaHref", label: "لینک دکمه", span: 12, ltr: true },
|
||||
{ kind: "text", key: "baseCardLabel", label: "برچسب کارت پایه", span: 12 },
|
||||
{ kind: "text", key: "customCardTitle", label: "عنوان کارت اختصاصی", span: 12 },
|
||||
{ kind: "text", key: "customCardBadge", label: "نشان کارت اختصاصی", span: 12 },
|
||||
{ kind: "text", key: "salesLabel", label: "برچسب فروش", span: 12 },
|
||||
{ kind: "text", key: "salesValue", label: "مبلغ فروش", span: 12 },
|
||||
{ kind: "text", key: "linkNote", label: "یادداشت اتصال", span: 12 },
|
||||
{ kind: "text", key: "aiLabel", label: "برچسب هوش مصنوعی", span: 8 },
|
||||
{ kind: "text", key: "aiStatus", label: "وضعیت", span: 8 },
|
||||
{ kind: "text", key: "aiNote", label: "توضیح", span: 8 },
|
||||
],
|
||||
},
|
||||
solutions: {
|
||||
description: "کارتهای محصولات؛ کلیک روی هر کارت فرم مشاوره را با زمینه انتخابشده باز میکند.",
|
||||
fields: [
|
||||
{ kind: "text", key: "eyebrow", label: "برچسب", span: 8 },
|
||||
{ kind: "text", key: "title", label: "عنوان", span: 8 },
|
||||
{ kind: "text", key: "subtitle", label: "زیرعنوان", span: 8 },
|
||||
{
|
||||
kind: "list",
|
||||
key: "items",
|
||||
label: "کارتها",
|
||||
itemLabel: "کارت",
|
||||
max: 10,
|
||||
fields: [
|
||||
{ kind: "text", key: "title", label: "عنوان", span: 12, required: true },
|
||||
{ kind: "text", key: "subtitle", label: "زیرعنوان", span: 12 },
|
||||
{ kind: "text", key: "consultType", label: "زمینه در فرم مشاوره", span: 8, hint: "باید با یکی از گزینههای فرم مشاوره یکسان باشد." },
|
||||
{
|
||||
kind: "select",
|
||||
key: "accent",
|
||||
label: "رنگ",
|
||||
span: 8,
|
||||
options: opt(SOLUTION_ACCENTS, { brand: "آبی برند", sky: "آسمانی", indigo: "نیلی", cyan: "فیروزهای", amber: "طلایی" }),
|
||||
},
|
||||
{
|
||||
kind: "select",
|
||||
key: "icon",
|
||||
label: "آیکون",
|
||||
span: 8,
|
||||
options: opt(SOLUTION_ICONS, { cube: "مکعب", bars: "نمودار", avatars: "کاربران", cart: "سبد خرید", gold: "شمش طلا" }),
|
||||
},
|
||||
],
|
||||
newItem: () => ({ title: "", subtitle: "", consultType: "سایر", accent: "brand", icon: "cube" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
testimonials: {
|
||||
description: "بخش اعتماد کسبوکارها و کارت نظر مشتری.",
|
||||
fields: [
|
||||
{ kind: "text", key: "eyebrow", label: "برچسب", span: 12 },
|
||||
{ kind: "text", key: "titleLine1", label: "خط اول تیتر", span: 12 },
|
||||
{ kind: "text", key: "titleLine2", label: "خط دوم تیتر (رنگی)", span: 12 },
|
||||
{ kind: "textarea", key: "body", label: "توضیح" },
|
||||
{ kind: "text", key: "ctaLabel", label: "متن دکمه", span: 12 },
|
||||
{ kind: "text", key: "badge", label: "نشان کارت", span: 12 },
|
||||
{ kind: "textarea", key: "quote", label: "متن نظر" },
|
||||
{ kind: "text", key: "name", label: "نام مشتری", span: 12 },
|
||||
{ kind: "text", key: "role", label: "سمت", span: 12 },
|
||||
{ kind: "image", key: "avatarUrl", label: "تصویر مشتری" },
|
||||
{ kind: "strings", key: "decorAvatars", label: "آواتارهای تزئینی", itemLabel: "تصویر", item: "image", fixed: 2 },
|
||||
],
|
||||
},
|
||||
cta: {
|
||||
description: "بنر آبی دعوت به مشاوره رایگان.",
|
||||
fields: [
|
||||
{ kind: "text", key: "titleLine1", label: "خط اول تیتر", span: 12 },
|
||||
{ kind: "text", key: "titleLine2", label: "خط دوم تیتر", span: 12 },
|
||||
{ kind: "textarea", key: "body", label: "توضیح" },
|
||||
{ kind: "text", key: "ctaLabel", label: "متن دکمه", span: 12 },
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
description: "ستونهای فوتر. تلفن، ایمیل، آدرس و شبکههای اجتماعی از «تنظیمات سایت» خوانده میشوند.",
|
||||
fields: [
|
||||
{ kind: "text", key: "contactTitle", label: "عنوان ستون تماس", span: 8 },
|
||||
{ kind: "text", key: "productsTitle", label: "عنوان ستون محصولات", span: 8 },
|
||||
{ kind: "text", key: "quickLinksTitle", label: "عنوان دسترسی سریع", span: 8 },
|
||||
{ kind: "list", key: "products", label: "لینکهای محصولات", itemLabel: "لینک", fields: linkFields, newItem: newLink, max: 10 },
|
||||
{ kind: "list", key: "quickLinks", label: "لینکهای دسترسی سریع", itemLabel: "لینک", fields: linkFields, newItem: newLink, max: 10 },
|
||||
{ kind: "text", key: "brandTitle", label: "نام برند", span: 12 },
|
||||
{ kind: "text", key: "brandSubtitle", label: "زیرعنوان برند", span: 12, ltr: true },
|
||||
{ kind: "strings", key: "descriptionLines", label: "خطوط توضیح برند", itemLabel: "خط", max: 3 },
|
||||
{ kind: "text", key: "copyright", label: "متن کپیرایت" },
|
||||
{ kind: "list", key: "legalLinks", label: "لینکهای حقوقی", itemLabel: "لینک", fields: linkFields, newItem: newLink, max: 4 },
|
||||
],
|
||||
},
|
||||
consult: {
|
||||
description: "پنجره درخواست مشاوره رایگان؛ درخواستها در بخش «درخواستهای مشاوره» ثبت میشوند.",
|
||||
fields: [
|
||||
{ kind: "text", key: "title", label: "عنوان پنجره" },
|
||||
{ kind: "text", key: "nameLabel", label: "برچسب نام", span: 12 },
|
||||
{ kind: "text", key: "namePlaceholder", label: "راهنمای نام", span: 12 },
|
||||
{ kind: "text", key: "phoneLabel", label: "برچسب تلفن", span: 12 },
|
||||
{ kind: "text", key: "phonePlaceholder", label: "راهنمای تلفن", span: 12, ltr: true },
|
||||
{ kind: "text", key: "typeLabel", label: "برچسب زمینه کسبوکار", span: 12 },
|
||||
{
|
||||
kind: "list",
|
||||
key: "types",
|
||||
label: "گزینههای زمینه کسبوکار",
|
||||
hint: "«مقدار» در درخواست ثبت میشود و باید با «زمینه در فرم مشاوره» کارتهای راهکار یکسان باشد.",
|
||||
itemLabel: "گزینه",
|
||||
max: 12,
|
||||
fields: [
|
||||
{ kind: "text", key: "value", label: "مقدار", span: 12, required: true },
|
||||
{ kind: "text", key: "label", label: "متن نمایشی", span: 12, required: true },
|
||||
],
|
||||
newItem: () => ({ value: "", label: "" }),
|
||||
},
|
||||
{ kind: "textarea", key: "successMessage", label: "پیام موفقیت" },
|
||||
{ kind: "textarea", key: "errorMessage", label: "پیام خطا" },
|
||||
{ kind: "text", key: "submitLabel", label: "متن دکمه ارسال", span: 12 },
|
||||
],
|
||||
},
|
||||
};
|
||||
+38
-62
@@ -1,70 +1,46 @@
|
||||
@import "./fonts.css";
|
||||
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #171a21;
|
||||
--border: #2a2f3a;
|
||||
--text: #e8e9ed;
|
||||
--muted: #9aa0ab;
|
||||
--accent: #c8ff2e;
|
||||
--danger: #ff6b6b;
|
||||
--radius: 10px;
|
||||
font-family: "IRANSans", Tahoma, sans-serif;
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "IRANSans", Tahoma, sans-serif;
|
||||
background: #f5f7fb;
|
||||
font-family: Vazirmatn, Tahoma, sans-serif;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input, textarea, select {
|
||||
font: inherit;
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.btn {
|
||||
border: 0;
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 4px 0 0;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ltr,
|
||||
.ltr input {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
background: var(--accent);
|
||||
color: #111;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn.danger { background: var(--danger); color: #fff; }
|
||||
.input, .textarea {
|
||||
width: 100%;
|
||||
background: #0c0e12;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.textarea { min-height: 140px; resize: vertical; }
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
}
|
||||
.label { display: block; margin-bottom: 0.35rem; color: var(--muted); font-size: 0.85rem; }
|
||||
.field { margin-bottom: 0.9rem; }
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th, .table td {
|
||||
text-align: right;
|
||||
padding: 0.65rem 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.muted { color: var(--muted); }
|
||||
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
@@ -1,63 +1,86 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 1rem">
|
||||
<h1>{{ isNew ? "پست جدید" : "ویرایش پست" }}</h1>
|
||||
<RouterLink class="btn ghost" to="/blog">بازگشت</RouterLink>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ isNew ? "پست جدید" : "ویرایش پست" }}</h1>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button v-if="!isNew && form.published" :href="`/blog/${form.slug}`" target="_blank">مشاهده</a-button>
|
||||
<a-button @click="router.push('/blog')">بازگشت</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<form class="card" @submit.prevent="save">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="label">عنوان</label>
|
||||
<input v-model="form.title" class="input" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">اسلاگ</label>
|
||||
<input v-model="form.slug" class="input" dir="ltr" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">خلاصه</label>
|
||||
<textarea v-model="form.excerpt" class="textarea" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">متن پست</label>
|
||||
<textarea v-model="form.content" class="textarea" style="min-height: 240px" placeholder="متن را عادی بنویسید. برای پاراگراف جدید Enter بزنید." />
|
||||
</div>
|
||||
<ImageField v-model="form.coverUrl" label="تصویر کاور" />
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="label">عنوان سئو</label>
|
||||
<input v-model="form.seoTitle" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">توضیح سئو</label>
|
||||
<input v-model="form.seoDesc" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">آدرس Canonical</label>
|
||||
<input v-model="form.canonical" class="input" dir="ltr" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label"><input v-model="form.published" type="checkbox" /> منتشر شود</label>
|
||||
</div>
|
||||
</div>
|
||||
<ImageField v-model="form.ogImage" label="تصویر اشتراکگذاری (اختیاری)" />
|
||||
<p v-if="msg" class="muted">{{ msg }}</p>
|
||||
<button class="btn">ذخیره</button>
|
||||
</form>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-form layout="vertical" :model="form" @finish="save">
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :lg="16">
|
||||
<a-card title="محتوا">
|
||||
<a-form-item label="عنوان" name="title" :rules="[{ required: true, message: 'عنوان الزامی است.' }]">
|
||||
<a-input v-model:value="form.title" @blur="suggestSlug" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="اسلاگ"
|
||||
name="slug"
|
||||
:rules="[
|
||||
{ required: true, message: 'اسلاگ الزامی است.' },
|
||||
{ pattern: SLUG_RE, message: 'فقط حروف انگلیسی کوچک، عدد و خط تیره.' },
|
||||
]"
|
||||
>
|
||||
<a-input v-model:value="form.slug" class="ltr" addon-before="/blog/" />
|
||||
</a-form-item>
|
||||
<a-form-item label="خلاصه" name="excerpt">
|
||||
<a-textarea v-model:value="form.excerpt" :auto-size="{ minRows: 2, maxRows: 4 }" show-count :maxlength="300" />
|
||||
</a-form-item>
|
||||
<a-form-item label="متن پست" name="content" extra="برای پاراگراف جدید یک خط خالی بگذارید.">
|
||||
<a-textarea v-model:value="form.content" :auto-size="{ minRows: 10 }" />
|
||||
</a-form-item>
|
||||
<a-form-item label="تصویر کاور">
|
||||
<ImageField v-model="form.coverUrl" />
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="8">
|
||||
<a-card title="انتشار">
|
||||
<a-form-item name="published">
|
||||
<a-switch v-model:checked="form.published" checked-children="منتشر" un-checked-children="پیشنویس" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" html-type="submit" block :loading="saving">ذخیره</a-button>
|
||||
</a-card>
|
||||
<a-card title="سئو" style="margin-top: 16px">
|
||||
<a-form-item label="عنوان سئو" name="seoTitle">
|
||||
<a-input v-model:value="form.seoTitle" show-count :maxlength="70" />
|
||||
</a-form-item>
|
||||
<a-form-item label="توضیح سئو" name="seoDesc">
|
||||
<a-textarea v-model:value="form.seoDesc" show-count :maxlength="170" :auto-size="{ minRows: 3 }" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Canonical" name="canonical">
|
||||
<a-input v-model:value="form.canonical" class="ltr" placeholder="https://montech.ir/blog/..." />
|
||||
</a-form-item>
|
||||
<a-form-item label="تصویر اشتراکگذاری">
|
||||
<ImageField v-model="form.ogImage" />
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { api } from "../lib/api";
|
||||
import { message } from "ant-design-vue";
|
||||
import { api, apiErrorMessage } from "../lib/api";
|
||||
import ImageField from "../components/ImageField.vue";
|
||||
|
||||
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const isNew = computed(() => route.name === "blog-new");
|
||||
const msg = ref("");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const form = reactive({
|
||||
title: "",
|
||||
slug: "",
|
||||
@@ -73,23 +96,43 @@ const form = reactive({
|
||||
|
||||
onMounted(async () => {
|
||||
if (isNew.value) return;
|
||||
const { data } = await api.get(`/admin/blog/${route.params.id}`);
|
||||
Object.assign(form, data);
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/blog/${route.params.id}`);
|
||||
for (const k of Object.keys(form) as Array<keyof typeof form>) {
|
||||
const v = data[k];
|
||||
if (v !== undefined && v !== null) (form as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function suggestSlug() {
|
||||
if (form.slug || !/[a-z0-9]/i.test(form.title)) return;
|
||||
form.slug = form.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
msg.value = "";
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isNew.value) {
|
||||
const { data } = await api.post("/admin/blog", form);
|
||||
msg.value = "ایجاد شد.";
|
||||
const { data } = await api.post<{ id: string }>("/admin/blog", form);
|
||||
message.success("پست ایجاد شد.");
|
||||
router.replace(`/blog/${data.id}`);
|
||||
} else {
|
||||
await api.put(`/admin/blog/${route.params.id}`, form);
|
||||
msg.value = "ذخیره شد.";
|
||||
message.success("ذخیره شد.");
|
||||
}
|
||||
} catch {
|
||||
msg.value = "خطا در ذخیره.";
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e, "خطا در ذخیره."));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,42 +1,88 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 1rem">
|
||||
<h1>بلاگ</h1>
|
||||
<RouterLink class="btn" to="/blog/new">پست جدید</RouterLink>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>بلاگ</h1>
|
||||
<p>پستهای منتشرشده در /blog نمایش داده میشوند.</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="router.push('/blog/new')">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
پست جدید
|
||||
</a-button>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>عنوان</th><th>اسلاگ</th><th>وضعیت</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in posts" :key="p.id">
|
||||
<td>{{ p.title }}</td>
|
||||
<td class="muted" dir="ltr">{{ p.slug }}</td>
|
||||
<td>{{ p.published ? "منتشر" : "پیشنویس" }}</td>
|
||||
<td class="row">
|
||||
<RouterLink :to="`/blog/${p.id}`">ویرایش</RouterLink>
|
||||
<button class="btn danger" @click="remove(p.id)">حذف</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<a-card :body-style="{ padding: 0 }">
|
||||
<a-table :columns="columns" :data-source="posts" :loading="loading" row-key="id" :pagination="{ pageSize: 20 }" :scroll="{ x: 640 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<RouterLink :to="`/blog/${record.id}`">{{ record.title }}</RouterLink>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'slug'">
|
||||
<span class="ltr">{{ record.slug }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'published'">
|
||||
<a-tag :color="record.published ? 'green' : 'default'">{{ record.published ? "منتشر" : "پیشنویس" }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button size="small" @click="router.push(`/blog/${record.id}`)">ویرایش</a-button>
|
||||
<a-popconfirm title="این پست حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(record.id)">
|
||||
<a-button size="small" danger>حذف</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../lib/api";
|
||||
import { useRouter } from "vue-router";
|
||||
import { message } from "ant-design-vue";
|
||||
import type { TableColumnsType } from "ant-design-vue";
|
||||
import { PlusOutlined } from "@ant-design/icons-vue";
|
||||
import { api, apiErrorMessage } from "../lib/api";
|
||||
|
||||
interface PostRow {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const posts = ref<PostRow[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const columns: TableColumnsType<PostRow> = [
|
||||
{ title: "عنوان", key: "title", dataIndex: "title" },
|
||||
{ title: "اسلاگ", key: "slug", dataIndex: "slug" },
|
||||
{ title: "وضعیت", key: "published", dataIndex: "published", width: 120 },
|
||||
{ title: "", key: "actions", width: 160 },
|
||||
];
|
||||
|
||||
const posts = ref<any[]>([]);
|
||||
async function load() {
|
||||
const { data } = await api.get("/admin/blog");
|
||||
posts.value = data;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<PostRow[]>("/admin/blog");
|
||||
posts.value = data;
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm("حذف شود؟")) return;
|
||||
await api.delete(`/admin/blog/${id}`);
|
||||
await load();
|
||||
try {
|
||||
await api.delete(`/admin/blog/${id}`);
|
||||
message.success("حذف شد.");
|
||||
await load();
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,80 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>داشبورد</h1>
|
||||
<p class="muted">سلام {{ auth.admin?.name || "ادمین" }} — مدیریت محتوای montech.ir</p>
|
||||
<div class="grid-2" style="margin-top: 1.25rem">
|
||||
<RouterLink class="card" to="/sections">ویرایش بخشهای لندینگ</RouterLink>
|
||||
<RouterLink class="card" to="/blog">مدیریت بلاگ</RouterLink>
|
||||
<RouterLink class="card" to="/gallery">گالری</RouterLink>
|
||||
<RouterLink class="card" to="/seo">سئو صفحات</RouterLink>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>داشبورد</h1>
|
||||
<p>سلام {{ auth.admin?.name || "ادمین" }} — مدیریت محتوای montech.ir</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card hoverable @click="router.push('/leads')">
|
||||
<a-statistic title="درخواستهای جدید" :value="stats.newLeads" :value-style="{ color: '#0062f5' }">
|
||||
<template #prefix><InboxOutlined /></template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card hoverable @click="router.push('/leads')">
|
||||
<a-statistic title="کل درخواستها" :value="stats.totalLeads">
|
||||
<template #prefix><TeamOutlined /></template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card hoverable @click="router.push('/blog')">
|
||||
<a-statistic title="پستهای بلاگ" :value="stats.posts">
|
||||
<template #prefix><FileTextOutlined /></template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card hoverable @click="router.push('/sections')">
|
||||
<a-statistic title="بخشهای سفارشیشده" :value="stats.customSections" :suffix="`/ ${stats.totalSections}`">
|
||||
<template #prefix><AppstoreOutlined /></template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card title="دسترسی سریع" style="margin-top: 16px">
|
||||
<a-space wrap>
|
||||
<a-button type="primary" @click="router.push('/sections/hero')">ویرایش هیرو</a-button>
|
||||
<a-button @click="router.push('/sections/solutions')">راهکارها</a-button>
|
||||
<a-button @click="router.push('/settings')">اطلاعات تماس</a-button>
|
||||
<a-button @click="router.push('/blog/new')">پست جدید</a-button>
|
||||
<a-button @click="router.push('/seo')">سئو صفحات</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { AppstoreOutlined, FileTextOutlined, InboxOutlined, TeamOutlined } from "@ant-design/icons-vue";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const stats = reactive({ newLeads: 0, totalLeads: 0, posts: 0, customSections: 0, totalSections: 0 });
|
||||
|
||||
onMounted(async () => {
|
||||
const [leads, posts, sections] = await Promise.allSettled([
|
||||
api.get<{ total: number; newCount: number }>("/admin/leads", { params: { pageSize: 1 } }),
|
||||
api.get<unknown[]>("/admin/blog"),
|
||||
api.get<Array<{ isDefault?: boolean }>>("/admin/sections"),
|
||||
]);
|
||||
if (leads.status === "fulfilled") {
|
||||
stats.newLeads = leads.value.data.newCount ?? 0;
|
||||
stats.totalLeads = leads.value.data.total ?? 0;
|
||||
}
|
||||
if (posts.status === "fulfilled") stats.posts = posts.value.data.length;
|
||||
if (sections.status === "fulfilled") {
|
||||
stats.totalSections = sections.value.data.length;
|
||||
stats.customSections = sections.value.data.filter((s) => !s.isDefault).length;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,70 +1,128 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>گالری</h1>
|
||||
<form class="card" style="margin-bottom: 1rem" @submit.prevent="create">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="label">عنوان</label>
|
||||
<input v-model="form.title" class="input" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">دسته</label>
|
||||
<input v-model="form.category" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">ترتیب</label>
|
||||
<input v-model.number="form.sortOrder" type="number" class="input" />
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>گالری</h1>
|
||||
<p>تصاویر صفحه /gallery به ترتیب عدد «ترتیب» نمایش داده میشوند.</p>
|
||||
</div>
|
||||
<ImageField v-model="form.imageUrl" label="تصویر" />
|
||||
<button class="btn" style="margin-top: 0.75rem" :disabled="!form.imageUrl">افزودن</button>
|
||||
</form>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>پیشنمایش</th><th>عنوان</th><th>دسته</th><th>ترتیب</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="g in items" :key="g.id">
|
||||
<td>
|
||||
<img v-if="g.imageUrl" :src="g.imageUrl" alt="" style="width:48px;height:48px;object-fit:cover;border-radius:6px" />
|
||||
</td>
|
||||
<td>{{ g.title }}</td>
|
||||
<td class="muted">{{ g.category || "—" }}</td>
|
||||
<td>{{ g.sortOrder }}</td>
|
||||
<td><button class="btn danger" @click="remove(g.id)">حذف</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a-card title="افزودن تصویر" style="margin-bottom: 16px">
|
||||
<a-form ref="formRef" layout="vertical" :model="form" @finish="create">
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="10">
|
||||
<a-form-item label="عنوان" name="title" :rules="[{ required: true, message: 'عنوان الزامی است.' }]">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دسته" name="category">
|
||||
<a-input v-model:value="form.category" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item label="ترتیب" name="sortOrder">
|
||||
<a-input-number v-model:value="form.sortOrder" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="تصویر" name="imageUrl" :rules="[{ required: true, message: 'تصویر را آپلود کنید.' }]">
|
||||
<ImageField v-model="form.imageUrl" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card :body-style="{ padding: 0 }">
|
||||
<a-table :columns="columns" :data-source="items" :loading="loading" row-key="id" :pagination="false" :scroll="{ x: 560 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'image'">
|
||||
<a-image v-if="record.imageUrl" :src="record.imageUrl" :width="48" :height="48" class="thumb" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'category'">
|
||||
<span>{{ record.category || "—" }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-popconfirm title="این تصویر حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(record.id)">
|
||||
<a-button size="small" danger>حذف</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { api } from "../lib/api";
|
||||
import { message } from "ant-design-vue";
|
||||
import type { FormInstance, TableColumnsType } from "ant-design-vue";
|
||||
import { PlusOutlined } from "@ant-design/icons-vue";
|
||||
import { api, apiErrorMessage } from "../lib/api";
|
||||
import ImageField from "../components/ImageField.vue";
|
||||
|
||||
const items = ref<any[]>([]);
|
||||
interface GalleryRow {
|
||||
id: string;
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
category: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
const items = ref<GalleryRow[]>([]);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = reactive({ title: "", imageUrl: "", category: "", sortOrder: 0, published: true });
|
||||
|
||||
const columns: TableColumnsType<GalleryRow> = [
|
||||
{ title: "پیشنمایش", key: "image", width: 96 },
|
||||
{ title: "عنوان", key: "title", dataIndex: "title" },
|
||||
{ title: "دسته", key: "category", dataIndex: "category" },
|
||||
{ title: "ترتیب", key: "sortOrder", dataIndex: "sortOrder", width: 90 },
|
||||
{ title: "", key: "actions", width: 90 },
|
||||
];
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get("/admin/gallery");
|
||||
items.value = data;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<GalleryRow[]>("/admin/gallery");
|
||||
items.value = data;
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
|
||||
async function create() {
|
||||
if (!form.imageUrl) return;
|
||||
await api.post("/admin/gallery", form);
|
||||
form.title = "";
|
||||
form.imageUrl = "";
|
||||
form.category = "";
|
||||
form.sortOrder = 0;
|
||||
await load();
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.post("/admin/gallery", form);
|
||||
message.success("تصویر اضافه شد.");
|
||||
formRef.value?.resetFields();
|
||||
Object.assign(form, { title: "", imageUrl: "", category: "", sortOrder: 0 });
|
||||
await load();
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm("حذف شود؟")) return;
|
||||
await api.delete(`/admin/gallery/${id}`);
|
||||
await load();
|
||||
try {
|
||||
await api.delete(`/admin/gallery/${id}`);
|
||||
message.success("حذف شد.");
|
||||
await load();
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>درخواستهای مشاوره</h1>
|
||||
<p>درخواستهای ثبتشده از فرم مشاوره، کارتهای راهکار و پیشنهاد هوشمند لندینگ.</p>
|
||||
</div>
|
||||
<a-space wrap>
|
||||
<a-segmented v-model:value="statusFilter" :options="filterOptions" @change="onFilter" />
|
||||
<a-button :loading="loading" title="بارگذاری مجدد" aria-label="بارگذاری مجدد" @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-card :body-style="{ padding: 0 }">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
:pagination="pagination"
|
||||
:scroll="{ x: 900 }"
|
||||
@change="onTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<strong>{{ record.name }}</strong>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'phone'">
|
||||
<a :href="`tel:${record.phone}`" class="ltr">{{ record.phone }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'source'">
|
||||
<a-tag :color="SOURCE_META[record.source as LeadSource]?.color">{{ SOURCE_META[record.source as LeadSource]?.label ?? record.source }}</a-tag>
|
||||
<a-tooltip v-if="record.aiQuery" :title="record.aiQuery">
|
||||
<InfoCircleOutlined style="color: #999" />
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-select
|
||||
:value="record.status"
|
||||
:options="statusOptions"
|
||||
size="small"
|
||||
style="width: 130px"
|
||||
@change="(v) => update(record as Lead, { status: v as LeadStatus })"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ new Date(record.createdAt).toLocaleString("fa-IR", { dateStyle: "medium", timeStyle: "short" }) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button size="small" @click="openNote(record as Lead)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
{{ record.note ? "یادداشت" : "افزودن یادداشت" }}
|
||||
</a-button>
|
||||
<a-popconfirm title="این درخواست حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(record.id)">
|
||||
<a-button size="small" danger title="حذف" aria-label="حذف درخواست">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
<template #expandedRowRender="{ record }">
|
||||
<a-descriptions size="small" :column="1">
|
||||
<a-descriptions-item v-if="record.aiQuery" label="پرسوجوی هوشمند">{{ record.aiQuery }}</a-descriptions-item>
|
||||
<a-descriptions-item label="یادداشت">{{ record.note || "—" }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal v-model:open="noteOpen" title="یادداشت پیگیری" ok-text="ذخیره" cancel-text="انصراف" :confirm-loading="noteSaving" @ok="saveNote">
|
||||
<a-textarea v-model:value="noteText" :auto-size="{ minRows: 4 }" :maxlength="2000" show-count />
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import type { TableColumnsType, TablePaginationConfig } from "ant-design-vue";
|
||||
import { DeleteOutlined, EditOutlined, InfoCircleOutlined, ReloadOutlined } from "@ant-design/icons-vue";
|
||||
import { LEAD_STATUSES, type Lead, type LeadSource, type LeadStatus } from "@montech/shared";
|
||||
import { api, apiErrorMessage } from "../lib/api";
|
||||
import { useLeadsCount } from "../composables/useLeadsCount";
|
||||
|
||||
const STATUS_LABELS: Record<LeadStatus, string> = { new: "جدید", contacted: "تماس گرفته شد", closed: "بسته شد" };
|
||||
const SOURCE_META: Record<LeadSource, { label: string; color: string }> = {
|
||||
consult: { label: "فرم مشاوره", color: "blue" },
|
||||
solution: { label: "کارت راهکار", color: "cyan" },
|
||||
ai: { label: "پیشنهاد هوشمند", color: "purple" },
|
||||
};
|
||||
|
||||
const statusOptions = LEAD_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }));
|
||||
const filterOptions = [{ value: "all", label: "همه" }, ...statusOptions];
|
||||
|
||||
const { refresh: refreshBadge } = useLeadsCount();
|
||||
const items = ref<Lead[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const loading = ref(false);
|
||||
const statusFilter = ref<"all" | LeadStatus>("new");
|
||||
|
||||
const pagination = computed<TablePaginationConfig>(() => ({
|
||||
current: page.value,
|
||||
pageSize: pageSize.value,
|
||||
total: total.value,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ["20", "50", "100"],
|
||||
}));
|
||||
|
||||
const columns: TableColumnsType<Lead> = [
|
||||
{ title: "نام", key: "name", dataIndex: "name" },
|
||||
{ title: "تلفن", key: "phone", dataIndex: "phone", width: 150 },
|
||||
{ title: "زمینه کسبوکار", key: "businessType", dataIndex: "businessType" },
|
||||
{ title: "منبع", key: "source", dataIndex: "source", width: 150 },
|
||||
{ title: "وضعیت", key: "status", dataIndex: "status", width: 150 },
|
||||
{ title: "تاریخ", key: "createdAt", dataIndex: "createdAt", width: 170 },
|
||||
{ title: "", key: "actions", width: 190 },
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<{ items: Lead[]; total: number }>("/admin/leads", {
|
||||
params: {
|
||||
status: statusFilter.value === "all" ? undefined : statusFilter.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
},
|
||||
});
|
||||
items.value = data.items;
|
||||
total.value = data.total;
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onFilter() {
|
||||
page.value = 1;
|
||||
load();
|
||||
}
|
||||
|
||||
function onTableChange(p: TablePaginationConfig) {
|
||||
page.value = p.current ?? 1;
|
||||
pageSize.value = p.pageSize ?? 20;
|
||||
load();
|
||||
}
|
||||
|
||||
async function update(record: Lead, patch: Partial<Pick<Lead, "status" | "note">>) {
|
||||
try {
|
||||
const { data } = await api.patch<Lead>(`/admin/leads/${record.id}`, patch);
|
||||
Object.assign(record, data);
|
||||
message.success("بهروزرسانی شد.");
|
||||
refreshBadge();
|
||||
if (patch.status && statusFilter.value !== "all" && patch.status !== statusFilter.value) load();
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
const noteOpen = ref(false);
|
||||
const noteText = ref("");
|
||||
const noteSaving = ref(false);
|
||||
let noteTarget: Lead | null = null;
|
||||
|
||||
function openNote(record: Lead) {
|
||||
noteTarget = record;
|
||||
noteText.value = record.note ?? "";
|
||||
noteOpen.value = true;
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
if (!noteTarget) return;
|
||||
noteSaving.value = true;
|
||||
await update(noteTarget, { note: noteText.value });
|
||||
noteSaving.value = false;
|
||||
noteOpen.value = false;
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
await api.delete(`/admin/leads/${id}`);
|
||||
message.success("حذف شد.");
|
||||
refreshBadge();
|
||||
await load();
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -1,32 +1,40 @@
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<form class="card form" @submit.prevent="submit">
|
||||
<h1>ورود ادمین Montech</h1>
|
||||
<p class="muted">موبایل و رمز حساب ادمین را وارد کنید.</p>
|
||||
<div class="field">
|
||||
<label class="label">موبایل</label>
|
||||
<input v-model="mobile" class="input" dir="ltr" autocomplete="username" />
|
||||
<a-card class="login-card" :bordered="false">
|
||||
<div class="head">
|
||||
<img src="/logo.svg" alt="" width="48" height="48" />
|
||||
<h1>ورود به پنل منتک</h1>
|
||||
<p>موبایل و رمز حساب ادمین را وارد کنید.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">رمز</label>
|
||||
<input v-model="password" type="password" class="input" dir="ltr" autocomplete="current-password" />
|
||||
</div>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<button class="btn" :disabled="loading">{{ loading ? "..." : "ورود" }}</button>
|
||||
</form>
|
||||
<a-form layout="vertical" :model="form" @finish="submit">
|
||||
<a-form-item label="موبایل" name="mobile" :rules="[{ required: true, message: 'موبایل را وارد کنید.' }]">
|
||||
<a-input v-model:value="form.mobile" class="ltr" autocomplete="username" size="large">
|
||||
<template #prefix><MobileOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="رمز عبور" name="password" :rules="[{ required: true, message: 'رمز را وارد کنید.' }]">
|
||||
<a-input-password v-model:value="form.password" class="ltr" autocomplete="current-password" size="large">
|
||||
<template #prefix><LockOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
<a-alert v-if="error" type="error" :message="error" show-icon style="margin-bottom: 16px" />
|
||||
<a-button type="primary" html-type="submit" size="large" block :loading="loading">ورود</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { LockOutlined, MobileOutlined } from "@ant-design/icons-vue";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { apiErrorMessage } from "../lib/api";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const mobile = ref("09121234567");
|
||||
const password = ref("");
|
||||
const form = reactive({ mobile: "09121234567", password: "" });
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
@@ -34,11 +42,11 @@ async function submit() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await auth.login(mobile.value, password.value);
|
||||
await auth.login(form.mobile, form.password);
|
||||
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
|
||||
router.replace(redirect);
|
||||
} catch {
|
||||
error.value = "ورود ناموفق بود.";
|
||||
} catch (e) {
|
||||
error.value = apiErrorMessage(e, "ورود ناموفق بود.");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -50,9 +58,24 @@ async function submit() {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
padding: 16px;
|
||||
background: radial-gradient(ellipse at top, #e6f2ff 0%, #f5f7fb 60%);
|
||||
}
|
||||
.login-card {
|
||||
width: min(420px, 100%);
|
||||
box-shadow: 0 20px 50px -20px rgba(0, 98, 245, 0.25);
|
||||
}
|
||||
.head {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.head h1 {
|
||||
margin: 12px 0 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.head p {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.form { width: min(420px, 100%); }
|
||||
h1 { margin: 0 0 0.4rem; font-size: 1.35rem; }
|
||||
.err { color: var(--danger); }
|
||||
</style>
|
||||
|
||||
@@ -1,59 +1,106 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>رسانه</h1>
|
||||
<form class="card" style="margin-bottom: 1rem" @submit.prevent="upload">
|
||||
<input type="file" @change="onFile" />
|
||||
<button class="btn" :disabled="!file">آپلود</button>
|
||||
<p v-if="msg" class="muted">{{ msg }}</p>
|
||||
</form>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>فایل</th><th>URL</th><th>حجم</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in items" :key="m.id">
|
||||
<td>{{ m.filename }}</td>
|
||||
<td class="muted" dir="ltr">{{ m.url }}</td>
|
||||
<td>{{ Math.round(m.size / 1024) }} KB</td>
|
||||
<td><button class="btn danger" @click="remove(m.id)">حذف</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>رسانه</h1>
|
||||
<p>فایلهای آپلودشده؛ آدرس را کپی کنید و در هر فیلد تصویر استفاده کنید.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-card style="margin-bottom: 16px">
|
||||
<a-upload-dragger :show-upload-list="false" :custom-request="upload" :multiple="true">
|
||||
<p class="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p class="ant-upload-text">فایل را اینجا رها کنید یا کلیک کنید</p>
|
||||
</a-upload-dragger>
|
||||
</a-card>
|
||||
|
||||
<a-card :body-style="{ padding: 0 }">
|
||||
<a-table :columns="columns" :data-source="items" :loading="loading" row-key="id" :pagination="{ pageSize: 20 }" :scroll="{ x: 640 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'preview'">
|
||||
<a-image v-if="isImage(record as MediaRow)" :src="record.url" :width="48" :height="48" class="thumb" />
|
||||
<FileOutlined v-else style="font-size: 24px; color: #999" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'url'">
|
||||
<a-typography-text :copyable="{ text: record.url }" class="ltr">{{ record.url }}</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'size'">{{ Math.round(record.size / 1024) }} KB</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-popconfirm title="این فایل حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(record.id)">
|
||||
<a-button size="small" danger>حذف</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../lib/api";
|
||||
import { message } from "ant-design-vue";
|
||||
import type { TableColumnsType } from "ant-design-vue";
|
||||
import type { UploadRequestOption } from "ant-design-vue/es/vc-upload/interface";
|
||||
import { FileOutlined, InboxOutlined } from "@ant-design/icons-vue";
|
||||
import { api, apiErrorMessage } from "../lib/api";
|
||||
|
||||
const items = ref<any[]>([]);
|
||||
const file = ref<File | null>(null);
|
||||
const msg = ref("");
|
||||
interface MediaRow {
|
||||
id: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
const items = ref<MediaRow[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const columns: TableColumnsType<MediaRow> = [
|
||||
{ title: "", key: "preview", width: 80 },
|
||||
{ title: "فایل", key: "filename", dataIndex: "filename" },
|
||||
{ title: "آدرس", key: "url" },
|
||||
{ title: "حجم", key: "size", width: 100 },
|
||||
{ title: "", key: "actions", width: 90 },
|
||||
];
|
||||
|
||||
function isImage(m: MediaRow) {
|
||||
return m.mimeType ? m.mimeType.startsWith("image/") : /\.(png|jpe?g|webp|gif|svg|avif)$/i.test(m.url);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get("/admin/media");
|
||||
items.value = data;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<MediaRow[]>("/admin/media");
|
||||
items.value = data;
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
|
||||
function onFile(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
file.value = input.files?.[0] || null;
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
if (!file.value) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", file.value);
|
||||
const { data } = await api.post("/admin/media/upload", fd);
|
||||
msg.value = `آپلود شد: ${data.url}`;
|
||||
file.value = null;
|
||||
await load();
|
||||
async function upload(options: UploadRequestOption) {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", options.file as Blob);
|
||||
const { data } = await api.post("/admin/media/upload", fd);
|
||||
options.onSuccess?.(data);
|
||||
message.success("آپلود شد.");
|
||||
await load();
|
||||
} catch (e) {
|
||||
options.onError?.(e as Error);
|
||||
message.error(apiErrorMessage(e, "آپلود ناموفق بود."));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm("حذف شود؟")) return;
|
||||
await api.delete(`/admin/media/${id}`);
|
||||
await load();
|
||||
try {
|
||||
await api.delete(`/admin/media/${id}`);
|
||||
message.success("حذف شد.");
|
||||
await load();
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,290 +1,131 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 1rem">
|
||||
<h1>{{ titleFa }}</h1>
|
||||
<RouterLink class="btn ghost" to="/sections">بازگشت</RouterLink>
|
||||
</div>
|
||||
|
||||
<form v-if="ready" class="card" @submit.prevent="save">
|
||||
<!-- HERO -->
|
||||
<template v-if="key === 'hero'">
|
||||
<div class="field"><label class="label">عنوان لودر ابتدای سایت</label><input v-model="form.loaderTitle" class="input" placeholder="هوشافزار منتک" /></div>
|
||||
<div class="field"><label class="label">برچسب بالای صفحه</label><input v-model="form.eyebrow" class="input" /></div>
|
||||
<div class="field"><label class="label">محل / سال تأسیس</label><input v-model="form.location" class="input" /></div>
|
||||
<div class="field">
|
||||
<label class="label">خطوط عنوان اصلی</label>
|
||||
<div v-for="(_line, i) in form.lines" :key="i" class="row" style="gap: 0.5rem; margin-bottom: 0.5rem">
|
||||
<input v-model="form.lines[i]" class="input" :placeholder="`خط ${i + 1}`" />
|
||||
<button type="button" class="btn danger" @click="form.lines.splice(i, 1)">حذف</button>
|
||||
</div>
|
||||
<button type="button" class="btn ghost" @click="form.lines.push('')">+ افزودن خط</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">عبارتهای رنگی (هر خط یک عبارت — رنگ volt و فونت تاکید)</label>
|
||||
<textarea v-model="highlightWordsText" class="textarea" style="min-height: 90px" placeholder="هوشافزار منتک" />
|
||||
<p class="muted" style="margin-top: 0.35rem">هر عبارتی که داخل خطوط عنوان باشد، با رنگ متفاوت نمایش داده میشود.</p>
|
||||
</div>
|
||||
<div class="field"><label class="label">توضیح کوتاه</label><textarea v-model="form.body" class="textarea" /></div>
|
||||
<div class="field"><label class="label">متن دکمه</label><input v-model="form.cta" class="input" /></div>
|
||||
<div class="field"><label class="label">مختصات / موقعیت</label><input v-model="form.coords" class="input" /></div>
|
||||
<div class="field"><label class="label">برچسب پروژهها</label><input v-model="form.projectsLabel" class="input" /></div>
|
||||
<a-page-header :title="title" :sub-title="schema?.description" style="padding: 0 0 16px" @back="router.push('/sections')">
|
||||
<template #extra>
|
||||
<a-tag v-if="isDefault" color="default">محتوای پیشفرض</a-tag>
|
||||
<a-popconfirm
|
||||
title="همه فیلدهای این بخش به متن اصلی طراحی برگردد؟"
|
||||
ok-text="بازگردانی"
|
||||
cancel-text="انصراف"
|
||||
@confirm="resetToDefaults"
|
||||
>
|
||||
<a-button>
|
||||
<template #icon><UndoOutlined /></template>
|
||||
بازگردانی پیشفرض
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button type="primary" :loading="saving" :disabled="!form" @click="save">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
ذخیره
|
||||
</a-button>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- MARQUEE -->
|
||||
<template v-else-if="key === 'marquee'">
|
||||
<div class="field">
|
||||
<label class="label">موارد نوار متحرک (هر خط یک مورد)</label>
|
||||
<textarea v-model="marqueeText" class="textarea" style="min-height: 200px" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- MANIFESTO -->
|
||||
<template v-else-if="key === 'manifesto'">
|
||||
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
|
||||
<div class="field">
|
||||
<label class="label">جملهٔ اصلی (کلمات را با فاصله جدا کنید؛ برای برجسته کردن کلمه را داخل *بگذارید*)</label>
|
||||
<textarea v-model="manifestoWordsText" class="textarea" />
|
||||
<p class="muted" style="margin-top: 0.35rem">مثال: ما نرمافزاری میسازیم که *تفاوت* ایجاد کند. MONTECH</p>
|
||||
</div>
|
||||
<div class="field"><label class="label">توضیح</label><textarea v-model="form.body" class="textarea" /></div>
|
||||
</template>
|
||||
|
||||
<!-- SERVICES -->
|
||||
<template v-else-if="key === 'services'">
|
||||
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
|
||||
<div v-for="(item, i) in form.items" :key="i" class="item-box">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<strong>خدمت {{ i + 1 }}</strong>
|
||||
<button type="button" class="btn danger" @click="form.items.splice(i, 1)">حذف</button>
|
||||
</div>
|
||||
<div class="field"><label class="label">شماره</label><input v-model="item.n" class="input" /></div>
|
||||
<div class="field"><label class="label">عنوان</label><input v-model="item.title" class="input" /></div>
|
||||
<div class="field"><label class="label">توضیح</label><textarea v-model="item.desc" class="textarea" /></div>
|
||||
<div class="field"><label class="label">برچسبها (با ویرگول)</label><input :value="(item.tags || []).join('، ')" class="input" @input="item.tags = splitTags(($event.target as HTMLInputElement).value)" /></div>
|
||||
</div>
|
||||
<button type="button" class="btn ghost" @click="form.items.push({ n: String(form.items.length + 1).padStart(2, '0'), title: '', desc: '', tags: [] })">+ افزودن خدمت</button>
|
||||
</template>
|
||||
|
||||
<!-- WORK -->
|
||||
<template v-else-if="key === 'work'">
|
||||
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
|
||||
<div v-for="(item, i) in form.items" :key="i" class="item-box">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<strong>نمونه کار {{ i + 1 }}</strong>
|
||||
<button type="button" class="btn danger" @click="form.items.splice(i, 1)">حذف</button>
|
||||
</div>
|
||||
<div class="field"><label class="label">شماره</label><input v-model="item.n" class="input" /></div>
|
||||
<div class="field"><label class="label">نام پروژه</label><input v-model="item.title" class="input" /></div>
|
||||
<div class="field"><label class="label">دستهبندی</label><input v-model="item.cat" class="input" /></div>
|
||||
<div class="field"><label class="label">سال</label><input v-model="item.year" class="input" /></div>
|
||||
<ImageField v-model="item.imageUrl" label="تصویر پروژه" placeholder="/api/uploads/..." />
|
||||
</div>
|
||||
<button type="button" class="btn ghost" @click="form.items.push({ n: String(form.items.length + 1).padStart(2, '0'), title: '', cat: '', year: '', imageUrl: '' })">+ افزودن پروژه</button>
|
||||
</template>
|
||||
|
||||
<!-- PROCESS -->
|
||||
<template v-else-if="key === 'process'">
|
||||
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
|
||||
<div v-for="(step, i) in form.steps" :key="i" class="item-box">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<strong>مرحله {{ i + 1 }}</strong>
|
||||
<button type="button" class="btn danger" @click="form.steps.splice(i, 1)">حذف</button>
|
||||
</div>
|
||||
<div class="field"><label class="label">شماره</label><input v-model="step.n" class="input" /></div>
|
||||
<div class="field"><label class="label">عنوان</label><input v-model="step.title" class="input" /></div>
|
||||
<div class="field"><label class="label">توضیح</label><textarea v-model="step.desc" class="textarea" /></div>
|
||||
</div>
|
||||
<button type="button" class="btn ghost" @click="form.steps.push({ n: String(form.steps.length + 1).padStart(2, '0'), title: '', desc: '' })">+ افزودن مرحله</button>
|
||||
</template>
|
||||
|
||||
<!-- STATS -->
|
||||
<template v-else-if="key === 'stats'">
|
||||
<div v-for="(item, i) in form.items" :key="i" class="item-box">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<strong>آمار {{ i + 1 }}</strong>
|
||||
<button type="button" class="btn danger" @click="form.items.splice(i, 1)">حذف</button>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field"><label class="label">عدد</label><input v-model.number="item.value" type="number" class="input" /></div>
|
||||
<div class="field"><label class="label">پسوند (+ یا %)</label><input v-model="item.suffix" class="input" /></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">برچسب</label><input v-model="item.label" class="input" /></div>
|
||||
</div>
|
||||
<button type="button" class="btn ghost" @click="form.items.push({ value: 0, suffix: '+', label: '' })">+ افزودن آمار</button>
|
||||
</template>
|
||||
|
||||
<!-- TEAM -->
|
||||
<template v-else-if="key === 'team'">
|
||||
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
|
||||
<div class="field"><label class="label">تیتر بزرگ</label><input v-model="form.headline" class="input" /></div>
|
||||
<div class="field"><label class="label">توضیح</label><textarea v-model="form.body" class="textarea" /></div>
|
||||
<div v-for="(m, i) in form.members" :key="i" class="item-box">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<strong>عضو {{ i + 1 }}</strong>
|
||||
<button type="button" class="btn danger" @click="form.members.splice(i, 1)">حذف</button>
|
||||
</div>
|
||||
<div class="field"><label class="label">نام</label><input v-model="m.name" class="input" /></div>
|
||||
<div class="field"><label class="label">سمت</label><input v-model="m.role" class="input" /></div>
|
||||
<ImageField v-model="m.imageUrl" label="تصویر عضو" placeholder="/api/uploads/..." />
|
||||
</div>
|
||||
<button type="button" class="btn ghost" @click="form.members.push({ name: '', role: '', imageUrl: '' })">+ افزودن عضو</button>
|
||||
</template>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<template v-else-if="key === 'footer'">
|
||||
<p class="muted" style="margin-bottom: 1rem">
|
||||
ایمیل، تلفن، آدرس و شبکههای اجتماعی از بخش «تنظیمات سایت» خوانده میشوند. اینجا فقط متنهای نمایشی فوتر را ویرایش کنید.
|
||||
</p>
|
||||
<div class="field"><label class="label">عنوان بخش</label><input v-model="form.tag" class="input" /></div>
|
||||
<div class="field"><label class="label">نوار متحرک پایین (هر خط یک مورد)</label><textarea v-model="footerMarqueeText" class="textarea" /></div>
|
||||
<div class="field"><label class="label">متن دعوت به همکاری</label><input v-model="form.cta" class="input" /></div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="muted">این بخش هنوز فرم اختصاصی ندارد. با پشتیبانی تماس بگیرید.</p>
|
||||
</template>
|
||||
|
||||
<p v-if="msg" class="muted" style="margin-top: 1rem">{{ msg }}</p>
|
||||
<button class="btn" style="margin-top: 0.75rem">ذخیره</button>
|
||||
</form>
|
||||
<a-result v-if="!schema" status="404" title="این بخش وجود ندارد." />
|
||||
<a-spin v-else :spinning="loading">
|
||||
<a-card>
|
||||
<a-form v-if="form" layout="vertical" @submit.prevent="save">
|
||||
<SchemaFields :fields="schema.fields" :model="form" :show-errors="showErrors" />
|
||||
</a-form>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { api } from "../lib/api";
|
||||
import ImageField from "../components/ImageField.vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { message } from "ant-design-vue";
|
||||
import { SaveOutlined, UndoOutlined } from "@ant-design/icons-vue";
|
||||
import { DEFAULT_SECTIONS, SECTION_KEYS, SECTION_TITLES_FA, resolveSection, type SectionKey } from "@montech/shared";
|
||||
import { SECTION_SCHEMAS, type Field } from "../sections/schema";
|
||||
import SchemaFields from "../components/SchemaFields.vue";
|
||||
import { api, apiErrorMessage } from "../lib/api";
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
hero: "صفحه اول (هیرو)",
|
||||
marquee: "نوار متحرک",
|
||||
manifesto: "معرفی / مانیفست",
|
||||
services: "خدمات",
|
||||
work: "نمونه کارها",
|
||||
process: "فرآیند کار",
|
||||
stats: "آمار",
|
||||
team: "تیم",
|
||||
footer: "تماس و فوتر",
|
||||
};
|
||||
type Model = Record<string, any>;
|
||||
|
||||
const route = useRoute();
|
||||
const key = String(route.params.key);
|
||||
const titleFa = computed(() => TITLES[key] || key);
|
||||
const ready = ref(false);
|
||||
const msg = ref("");
|
||||
const form = reactive<any>({});
|
||||
const router = useRouter();
|
||||
const rawKey = String(route.params.key);
|
||||
const key = (SECTION_KEYS as readonly string[]).includes(rawKey) ? (rawKey as SectionKey) : null;
|
||||
const schema = key ? SECTION_SCHEMAS[key] : null;
|
||||
const title = computed(() => (key ? SECTION_TITLES_FA[key] : rawKey));
|
||||
|
||||
const marqueeText = ref("");
|
||||
const manifestoWordsText = ref("");
|
||||
const footerMarqueeText = ref("");
|
||||
const highlightWordsText = ref("");
|
||||
const form = ref<Model | null>(null);
|
||||
const isDefault = ref(false);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const showErrors = ref(false);
|
||||
|
||||
function splitTags(v: string) {
|
||||
return v
|
||||
.split(/[,،]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function wordsToText(words: Array<{ text: string; outline?: boolean; volt?: boolean }>) {
|
||||
return (words || [])
|
||||
.map((w) => (w.outline || w.volt ? `*${w.text}*` : w.text))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function textToWords(text: string) {
|
||||
return text
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((token) => {
|
||||
const m = token.match(/^\*(.+)\*$/);
|
||||
if (m) return { text: m[1], outline: false, volt: true };
|
||||
return { text: token, outline: false, volt: false };
|
||||
});
|
||||
function clone<T>(v: T): T {
|
||||
return JSON.parse(JSON.stringify(v));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get(`/admin/sections/${key}`);
|
||||
Object.assign(form, structuredClone(data.data));
|
||||
|
||||
if (key === "hero") {
|
||||
form.lines = Array.isArray(form.lines) ? [...form.lines] : [""];
|
||||
if (!form.lines.length) form.lines.push("");
|
||||
form.loaderTitle = form.loaderTitle || "هوشافزار منتک";
|
||||
const words = Array.isArray(form.highlightWords) && form.highlightWords.length
|
||||
? form.highlightWords
|
||||
: form.highlightWord
|
||||
? [form.highlightWord]
|
||||
: [];
|
||||
highlightWordsText.value = words.join("\n");
|
||||
if (!key) return;
|
||||
try {
|
||||
const { data } = await api.get<{ data: Model; isDefault?: boolean }>(`/admin/sections/${key}`);
|
||||
form.value = clone(resolveSection(key, data.data as never)) as Model;
|
||||
isDefault.value = Boolean(data.isDefault);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
form.value = clone(DEFAULT_SECTIONS[key]) as unknown as Model;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
if (key === "marquee") marqueeText.value = (form.items || []).join("\n");
|
||||
if (key === "manifesto") manifestoWordsText.value = wordsToText(form.words || []);
|
||||
if (key === "services" || key === "work" || key === "stats") form.items = form.items || [];
|
||||
if (key === "work") {
|
||||
form.items = form.items.map((i: any) => ({ ...i, imageUrl: i.imageUrl || "" }));
|
||||
}
|
||||
if (key === "process") form.steps = form.steps || [];
|
||||
if (key === "team") {
|
||||
form.members = (form.members || []).map((m: any) => ({ ...m, imageUrl: m.imageUrl || "" }));
|
||||
}
|
||||
if (key === "footer") {
|
||||
footerMarqueeText.value = (form.marquee || []).join("\n");
|
||||
}
|
||||
ready.value = true;
|
||||
});
|
||||
|
||||
function buildPayload() {
|
||||
if (key === "marquee") return { items: marqueeText.value.split("\n").map((s) => s.trim()).filter(Boolean) };
|
||||
if (key === "manifesto") {
|
||||
return { tag: form.tag, body: form.body, words: textToWords(manifestoWordsText.value) };
|
||||
function hasMissing(fields: Field[], model: Model): boolean {
|
||||
return fields.some((f) => {
|
||||
if ((f.kind === "text" || f.kind === "textarea") && f.required) {
|
||||
const v = model[f.key];
|
||||
return typeof v !== "string" || !v.trim();
|
||||
}
|
||||
if (f.kind === "group") return hasMissing(f.fields, model[f.key] ?? {});
|
||||
if (f.kind === "list") return (model[f.key] ?? []).some((item: Model) => hasMissing(f.fields, item));
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function stripEmptyStrings(fields: Field[], model: Model) {
|
||||
for (const f of fields) {
|
||||
if (f.kind === "strings" && !f.fixed && Array.isArray(model[f.key])) {
|
||||
model[f.key] = model[f.key].map((s: string) => String(s ?? "").trim()).filter(Boolean);
|
||||
} else if (f.kind === "group" && model[f.key]) {
|
||||
stripEmptyStrings(f.fields, model[f.key]);
|
||||
} else if (f.kind === "list" && Array.isArray(model[f.key])) {
|
||||
model[f.key].forEach((item: Model) => stripEmptyStrings(f.fields, item));
|
||||
}
|
||||
}
|
||||
if (key === "hero") {
|
||||
const words = highlightWordsText.value
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
...form,
|
||||
lines: (form.lines || []).map((s: string) => String(s || "").trim()).filter(Boolean),
|
||||
highlightWords: words,
|
||||
highlightWord: words[0] || form.highlightWord || "",
|
||||
loaderTitle: form.loaderTitle || "هوشافزار منتک",
|
||||
};
|
||||
}
|
||||
if (key === "footer") {
|
||||
return {
|
||||
tag: form.tag,
|
||||
cta: form.cta,
|
||||
marquee: footerMarqueeText.value.split("\n").map((s) => s.trim()).filter(Boolean),
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
address: form.address,
|
||||
socials: form.socials,
|
||||
};
|
||||
}
|
||||
return { ...form };
|
||||
}
|
||||
|
||||
async function save() {
|
||||
msg.value = "";
|
||||
if (!key || !schema || !form.value) return;
|
||||
if (hasMissing(schema.fields, form.value)) {
|
||||
showErrors.value = true;
|
||||
message.warning("فیلدهای الزامی را تکمیل کنید.");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.put(`/admin/sections/${key}`, { data: buildPayload() });
|
||||
msg.value = "ذخیره شد.";
|
||||
} catch {
|
||||
msg.value = "خطا در ذخیره.";
|
||||
const payload = clone(form.value);
|
||||
stripEmptyStrings(schema.fields, payload);
|
||||
await api.put(`/admin/sections/${key}`, { data: payload });
|
||||
form.value = payload;
|
||||
isDefault.value = false;
|
||||
showErrors.value = false;
|
||||
message.success("ذخیره شد. تغییرات حداکثر تا ۳۰ ثانیه دیگر روی سایت دیده میشود.");
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e, "خطا در ذخیره."));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-box {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.9rem;
|
||||
margin-bottom: 0.9rem;
|
||||
background: #12151b;
|
||||
function resetToDefaults() {
|
||||
if (!key) return;
|
||||
form.value = clone(DEFAULT_SECTIONS[key]) as unknown as Model;
|
||||
showErrors.value = false;
|
||||
message.info("متن پیشفرض جایگزین شد؛ برای اعمال، ذخیره کنید.");
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
|
||||
@@ -1,44 +1,74 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>محتوای لندینگ</h1>
|
||||
<p class="muted">متنها را مثل فرم معمولی ویرایش کنید — نیازی به کدنویسی نیست.</p>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>بخش</th><th>آخرین ویرایش</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in sections" :key="s.id">
|
||||
<td>{{ label(s.key) }}</td>
|
||||
<td class="muted">{{ new Date(s.updatedAt).toLocaleString("fa-IR") }}</td>
|
||||
<td><RouterLink :to="`/sections/${s.key}`">ویرایش</RouterLink></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>محتوای لندینگ</h1>
|
||||
<p>هر بخش صفحه اصلی را مثل یک فرم ساده ویرایش کنید.</p>
|
||||
</div>
|
||||
<a-button href="/" target="_blank">
|
||||
<template #icon><GlobalOutlined /></template>
|
||||
مشاهده لندینگ
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col v-for="s in rows" :key="s.key" :xs="24" :md="12" :xl="8">
|
||||
<a-card hoverable class="section-card" @click="router.push(`/sections/${s.key}`)">
|
||||
<a-card-meta :title="SECTION_TITLES_FA[s.key]" :description="SECTION_SCHEMAS[s.key].description" />
|
||||
<div class="meta">
|
||||
<a-tag v-if="s.isDefault" color="default">محتوای پیشفرض</a-tag>
|
||||
<a-tag v-else color="blue">سفارشیشده</a-tag>
|
||||
<a-typography-text v-if="s.updatedAt && !s.isDefault" type="secondary">
|
||||
{{ new Date(s.updatedAt).toLocaleString("fa-IR") }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-skeleton v-if="loading" active />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../lib/api";
|
||||
import { useRouter } from "vue-router";
|
||||
import { message } from "ant-design-vue";
|
||||
import { GlobalOutlined } from "@ant-design/icons-vue";
|
||||
import { SECTION_KEYS, SECTION_TITLES_FA, type SectionKey } from "@montech/shared";
|
||||
import { SECTION_SCHEMAS } from "../sections/schema";
|
||||
import { api, apiErrorMessage } from "../lib/api";
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
hero: "صفحه اول (هیرو)",
|
||||
marquee: "نوار متحرک",
|
||||
manifesto: "معرفی",
|
||||
services: "خدمات",
|
||||
work: "نمونه کارها",
|
||||
process: "فرآیند کار",
|
||||
stats: "آمار",
|
||||
team: "تیم",
|
||||
footer: "تماس و فوتر",
|
||||
};
|
||||
|
||||
const sections = ref<any[]>([]);
|
||||
function label(key: string) {
|
||||
return TITLES[key] || key;
|
||||
interface SectionRow {
|
||||
key: SectionKey;
|
||||
updatedAt: string | null;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const rows = ref<SectionRow[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get("/admin/sections");
|
||||
sections.value = data;
|
||||
try {
|
||||
const { data } = await api.get<SectionRow[]>("/admin/sections");
|
||||
const byKey = new Map(data.map((s) => [s.key, s]));
|
||||
rows.value = SECTION_KEYS.map((key) => byKey.get(key) ?? { key, updatedAt: null, isDefault: true });
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
height: 100%;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,52 +1,104 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>سئو صفحات</h1>
|
||||
<div v-for="page in pages" :key="page.id" class="card" style="margin-bottom: 1rem">
|
||||
<h2 style="margin-top: 0">{{ page.title }} <span class="muted">({{ page.key }})</span></h2>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="label">Title</label>
|
||||
<input v-model="page.seo.title" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Canonical</label>
|
||||
<input v-model="page.seo.canonical" class="input" dir="ltr" />
|
||||
</div>
|
||||
<div class="field" style="grid-column: 1 / -1">
|
||||
<label class="label">Description</label>
|
||||
<textarea v-model="page.seo.description" class="textarea" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">OG Image</label>
|
||||
<input v-model="page.seo.ogImage" class="input" dir="ltr" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label"><input v-model="page.seo.noIndex" type="checkbox" /> noindex</label>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>سئو صفحات</h1>
|
||||
<p>عنوان و توضیحی که موتورهای جستجو و شبکههای اجتماعی نمایش میدهند.</p>
|
||||
</div>
|
||||
<button class="btn" @click="save(page)">ذخیره</button>
|
||||
<span v-if="msg[page.key]" class="muted" style="margin-right: 0.75rem">{{ msg[page.key] }}</span>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-collapse v-model:active-key="active" accordion>
|
||||
<a-collapse-panel v-for="page in pages" :key="page.key">
|
||||
<template #header>
|
||||
<strong>{{ page.title }}</strong>
|
||||
<a-typography-text type="secondary" class="ltr" style="margin-inline-start: 8px">{{ page.key }}</a-typography-text>
|
||||
<a-tag v-if="page.seo.noIndex" color="orange" style="margin-inline-start: 8px">noindex</a-tag>
|
||||
</template>
|
||||
<a-form layout="vertical" :model="page.seo" @finish="save(page)">
|
||||
<a-form-item label="عنوان (Title)" name="title" :rules="[{ required: true, message: 'الزامی است.' }]">
|
||||
<a-input v-model:value="page.seo.title" show-count :maxlength="70" />
|
||||
</a-form-item>
|
||||
<a-form-item label="توضیح (Description)" name="description">
|
||||
<a-textarea v-model:value="page.seo.description" show-count :maxlength="170" :auto-size="{ minRows: 2 }" />
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="Canonical" name="canonical">
|
||||
<a-input v-model:value="page.seo.canonical" class="ltr" placeholder="https://montech.ir/..." />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="تصویر اشتراکگذاری (OG)">
|
||||
<ImageField v-model="page.seo.ogImage" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item name="noIndex">
|
||||
<a-checkbox v-model:checked="page.seo.noIndex">عدم نمایش در موتورهای جستجو (noindex)</a-checkbox>
|
||||
</a-form-item>
|
||||
<a-button type="primary" html-type="submit" :loading="saving === page.key">ذخیره</a-button>
|
||||
</a-form>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { api } from "../lib/api";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import { api, apiErrorMessage } from "../lib/api";
|
||||
import ImageField from "../components/ImageField.vue";
|
||||
|
||||
const pages = ref<any[]>([]);
|
||||
const msg = reactive<Record<string, string>>({});
|
||||
interface SeoForm {
|
||||
title: string;
|
||||
description: string;
|
||||
canonical: string;
|
||||
ogImage: string;
|
||||
noIndex: boolean;
|
||||
}
|
||||
interface PageRow {
|
||||
key: string;
|
||||
title: string;
|
||||
seo: SeoForm;
|
||||
}
|
||||
|
||||
const pages = ref<PageRow[]>([]);
|
||||
const active = ref<string>("home");
|
||||
const loading = ref(true);
|
||||
const saving = ref<string | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get("/admin/pages");
|
||||
pages.value = data.map((p: any) => ({
|
||||
...p,
|
||||
seo: p.seo || { title: p.title, description: "", canonical: "", ogImage: "", noIndex: false },
|
||||
}));
|
||||
try {
|
||||
const { data } = await api.get<Array<{ key: string; title: string; seo: Partial<SeoForm> | null }>>("/admin/pages");
|
||||
pages.value = data.map((p) => ({
|
||||
key: p.key,
|
||||
title: p.title,
|
||||
seo: {
|
||||
title: p.seo?.title || p.title,
|
||||
description: p.seo?.description || "",
|
||||
canonical: p.seo?.canonical || "",
|
||||
ogImage: p.seo?.ogImage || "",
|
||||
noIndex: Boolean(p.seo?.noIndex),
|
||||
},
|
||||
}));
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function save(page: any) {
|
||||
await api.put(`/admin/pages/${page.key}/seo`, page.seo);
|
||||
msg[page.key] = "ذخیره شد.";
|
||||
async function save(page: PageRow) {
|
||||
saving.value = page.key;
|
||||
try {
|
||||
await api.put(`/admin/pages/${page.key}/seo`, page.seo);
|
||||
message.success("ذخیره شد.");
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
saving.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
+132
-109
@@ -1,137 +1,160 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>تنظیمات سایت</h1>
|
||||
<p class="muted">این مقادیر روی هدر، فوتر و کل سایت اعمال میشوند.</p>
|
||||
<form v-if="form" class="card" @submit.prevent="save">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="label">نام برند</label>
|
||||
<input v-model="form.brandName" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">تگلاین</label>
|
||||
<input v-model="form.tagline" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">ایمیل</label>
|
||||
<input v-model="form.email" class="input" dir="ltr" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">تلفن</label>
|
||||
<input v-model="form.phone" class="input" dir="ltr" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">آدرس</label>
|
||||
<input v-model="form.address" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">منطقه زمانی</label>
|
||||
<select v-model="form.timezone" class="input" dir="ltr">
|
||||
<option value="Asia/Tehran">Asia/Tehran (ایران)</option>
|
||||
<option value="UTC">UTC</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>تنظیمات سایت</h1>
|
||||
<p>اطلاعات برند و راههای ارتباطی کل سایت.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageField
|
||||
v-model="form.logoUrl"
|
||||
label="لوگو"
|
||||
placeholder="/api/uploads/..."
|
||||
hint="لوگو در هدر سایت نمایش داده میشود. اگر خالی باشد نام برند نشان داده میشود."
|
||||
/>
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
message="تلفن، ایمیل، آدرس و شبکههای اجتماعی مستقیماً در ستون «تماس با ما» و آیکونهای فوتر لندینگ نمایش داده میشوند."
|
||||
/>
|
||||
|
||||
<h2 style="margin: 1.25rem 0 0.75rem; font-size: 1rem">شبکههای اجتماعی</h2>
|
||||
<div v-for="(s, i) in socials" :key="i" class="item-box">
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 0.5rem">
|
||||
<strong>مورد {{ i + 1 }}</strong>
|
||||
<button type="button" class="btn danger" @click="socials.splice(i, 1)">حذف</button>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="label">نام</label>
|
||||
<input v-model="s.label" class="input" />
|
||||
<a-spin :spinning="loading">
|
||||
<a-form layout="vertical" :model="form" @finish="save">
|
||||
<a-card title="برند و تماس">
|
||||
<a-row :gutter="16">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام برند" name="brandName" :rules="[{ required: true, message: 'الزامی است.' }]">
|
||||
<a-input v-model:value="form.brandName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="تگلاین" name="tagline">
|
||||
<a-input v-model:value="form.tagline" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="تلفن" name="phone">
|
||||
<a-input v-model:value="form.phone" placeholder="۰۹۱۲ ۳۴۵ ۶۷۸۹" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="ایمیل" name="email" :rules="[{ type: 'email', message: 'ایمیل معتبر نیست.' }]">
|
||||
<a-input v-model:value="form.email" class="ltr" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="آدرس" name="address">
|
||||
<a-input v-model:value="form.address" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="منطقه زمانی" name="timezone">
|
||||
<a-select v-model:value="form.timezone" :options="TIMEZONES" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="لوگو" extra="در صورت خالی بودن، نشان پیشفرض منتک نمایش داده میشود.">
|
||||
<ImageField v-model="form.logoUrl" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<a-card title="شبکههای اجتماعی" style="margin-top: 16px">
|
||||
<template #extra>
|
||||
<a-typography-text type="secondary">آیکون بر اساس نام یا لینک (تلگرام، لینکدین، اینستاگرام) انتخاب میشود.</a-typography-text>
|
||||
</template>
|
||||
<div v-for="(s, i) in form.socials" :key="i" class="social-row">
|
||||
<a-input v-model:value="s.label" placeholder="نام (مثلاً تلگرام)" />
|
||||
<a-input v-model:value="s.url" class="ltr" placeholder="https://t.me/..." />
|
||||
<a-button danger type="text" title="حذف" aria-label="حذف شبکه اجتماعی" @click="form.socials.splice(i, 1)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">لینک</label>
|
||||
<input v-model="s.url" class="input" dir="ltr" placeholder="https://..." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn ghost" @click="socials.push({ label: '', url: '' })">+ افزودن</button>
|
||||
<a-button type="dashed" block @click="form.socials.push({ label: '', url: '' })">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن شبکه اجتماعی
|
||||
</a-button>
|
||||
</a-card>
|
||||
|
||||
<p v-if="msg" class="muted" style="margin-top: 1rem">{{ msg }}</p>
|
||||
<button class="btn" style="margin-top: 0.75rem">ذخیره</button>
|
||||
</form>
|
||||
<div class="actions">
|
||||
<a-button type="primary" html-type="submit" size="large" :loading="saving">ذخیره تنظیمات</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { api } from "../lib/api";
|
||||
import { message } from "ant-design-vue";
|
||||
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons-vue";
|
||||
import type { PublicSettings, SocialLink } from "@montech/shared";
|
||||
import { api, apiErrorMessage } from "../lib/api";
|
||||
import ImageField from "../components/ImageField.vue";
|
||||
|
||||
const form = ref<{
|
||||
brandName: string;
|
||||
tagline: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
timezone: string;
|
||||
logoUrl: string;
|
||||
} | null>(null);
|
||||
const TIMEZONES = [
|
||||
{ value: "Asia/Tehran", label: "Asia/Tehran (ایران)" },
|
||||
{ value: "UTC", label: "UTC" },
|
||||
];
|
||||
|
||||
const socials = reactive<Array<{ label: string; url: string }>>([]);
|
||||
const msg = ref("");
|
||||
const form = reactive({
|
||||
brandName: "",
|
||||
tagline: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
timezone: "Asia/Tehran",
|
||||
logoUrl: "",
|
||||
socials: [] as SocialLink[],
|
||||
});
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get("/admin/settings");
|
||||
form.value = {
|
||||
brandName: data.brandName || "",
|
||||
tagline: data.tagline || "",
|
||||
email: data.email || "",
|
||||
phone: data.phone || "",
|
||||
address: data.address || "",
|
||||
timezone: data.timezone || "Asia/Tehran",
|
||||
logoUrl: data.logoUrl || "",
|
||||
};
|
||||
socials.splice(0, socials.length, ...(Array.isArray(data.socials) ? data.socials : []));
|
||||
if (!socials.length) socials.push({ label: "", url: "" });
|
||||
try {
|
||||
const { data } = await api.get<PublicSettings>("/admin/settings");
|
||||
Object.assign(form, {
|
||||
brandName: data.brandName || "",
|
||||
tagline: data.tagline || "",
|
||||
email: data.email || "",
|
||||
phone: data.phone || "",
|
||||
address: data.address || "",
|
||||
timezone: data.timezone || "Asia/Tehran",
|
||||
logoUrl: data.logoUrl || "",
|
||||
socials: Array.isArray(data.socials) ? data.socials.map((s) => ({ ...s })) : [],
|
||||
});
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!form.value) return;
|
||||
msg.value = "";
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
brandName: form.value.brandName,
|
||||
tagline: form.value.tagline,
|
||||
email: form.value.email,
|
||||
phone: form.value.phone,
|
||||
address: form.value.address,
|
||||
timezone: form.value.timezone,
|
||||
logoUrl: form.value.logoUrl || null,
|
||||
socials: socials.filter((s) => s.label.trim() || s.url.trim()),
|
||||
};
|
||||
const { data } = await api.put("/admin/settings", payload);
|
||||
form.value.logoUrl = data.logoUrl || "";
|
||||
msg.value = "ذخیره شد. برای دیدن در سایت صفحه را رفرش کنید.";
|
||||
} catch (e: any) {
|
||||
const detail = e?.response?.data?.message;
|
||||
msg.value = Array.isArray(detail)
|
||||
? `خطا: ${detail.join("، ")}`
|
||||
: detail
|
||||
? `خطا: ${detail}`
|
||||
: "خطا در ذخیره.";
|
||||
const { data } = await api.put<PublicSettings>("/admin/settings", {
|
||||
...form,
|
||||
logoUrl: form.logoUrl || null,
|
||||
socials: form.socials.filter((s) => s.label.trim() || s.url.trim()),
|
||||
});
|
||||
form.logoUrl = data.logoUrl || "";
|
||||
message.success("ذخیره شد. تغییرات حداکثر تا ۳۰ ثانیه دیگر روی سایت دیده میشود.");
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e, "خطا در ذخیره."));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-box {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.9rem;
|
||||
margin-bottom: 0.9rem;
|
||||
background: #12151b;
|
||||
.social-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@montech/shared": ["../../packages/shared/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
|
||||
+14
-2
@@ -1,12 +1,24 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import Components from "unplugin-vue-components/vite";
|
||||
import { AntDesignVueResolver } from "unplugin-vue-components/resolvers";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
Components({
|
||||
dts: "src/components.d.ts",
|
||||
dirs: [],
|
||||
resolvers: [AntDesignVueResolver({ importStyle: false, resolveIcons: true })],
|
||||
}),
|
||||
],
|
||||
base: "/adm/",
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "src") },
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
"@montech/shared": path.resolve(__dirname, "../../packages/shared/src/index.ts"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
|
||||
Reference in New Issue
Block a user