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:
2026-10-01 20:36:06 +03:30
co-authored by Cursor
parent 5147c661f0
commit b0088c35d1
130 changed files with 8024 additions and 2801 deletions
+3 -1
View File
@@ -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>
+5
View File
@@ -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"
}
+14
View File
@@ -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
View File
@@ -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>
+58
View File
@@ -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']
}
}
+38 -43
View File
@@ -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>
+163
View File
@@ -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>
+16
View File
@@ -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 };
}
-65
View File
@@ -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;
}
+138 -42
View File
@@ -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
View File
@@ -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;
}
+4
View File
@@ -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";
+2
View File
@@ -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: "/" },
],
});
+260
View File
@@ -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
View File
@@ -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;
}
+97 -54
View File
@@ -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>
+72 -26
View File
@@ -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>
+70 -7
View File
@@ -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>
+106 -48
View File
@@ -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>
+198
View File
@@ -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>
+46 -23
View File
@@ -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>
+88 -41
View File
@@ -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>
+103 -262
View File
@@ -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>
+62 -32
View File
@@ -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>
+89 -37
View File
@@ -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
View File
@@ -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>
+4 -1
View File
@@ -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
View File
@@ -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,