Make admin section editor form-based and localize Montech site to Persian.
Operators can edit landing copy as plain fields instead of JSON/HTML; default CMS seed and UI chrome are now Farsi/RTL. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,28 +20,28 @@
|
||||
<textarea v-model="form.excerpt" class="textarea" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">محتوا (HTML)</label>
|
||||
<textarea v-model="form.content" class="textarea" dir="ltr" style="min-height: 240px" />
|
||||
<label class="label">متن پست</label>
|
||||
<textarea v-model="form.content" class="textarea" style="min-height: 240px" placeholder="متن را عادی بنویسید. برای پاراگراف جدید Enter بزنید." />
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label class="label">کاور URL</label>
|
||||
<input v-model="form.coverUrl" class="input" dir="ltr" />
|
||||
<label class="label">آدرس تصویر کاور</label>
|
||||
<input v-model="form.coverUrl" class="input" dir="ltr" placeholder="از بخش رسانه آپلود کنید و آدرس را اینجا بگذارید" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">SEO Title</label>
|
||||
<label class="label">عنوان سئو</label>
|
||||
<input v-model="form.seoTitle" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">SEO Description</label>
|
||||
<label class="label">توضیح سئو</label>
|
||||
<input v-model="form.seoDesc" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Canonical</label>
|
||||
<label class="label">آدرس Canonical</label>
|
||||
<input v-model="form.canonical" class="input" dir="ltr" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">OG Image</label>
|
||||
<label class="label">تصویر اشتراکگذاری</label>
|
||||
<input v-model="form.ogImage" class="input" dir="ltr" />
|
||||
</div>
|
||||
<div class="field">
|
||||
|
||||
@@ -1,40 +1,253 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 1rem">
|
||||
<h1>بخش: {{ key }}</h1>
|
||||
<h1>{{ titleFa }}</h1>
|
||||
<RouterLink class="btn ghost" to="/sections">بازگشت</RouterLink>
|
||||
</div>
|
||||
<div class="card">
|
||||
<textarea v-model="jsonText" class="textarea" dir="ltr" style="min-height: 420px" />
|
||||
<p v-if="msg" class="muted">{{ msg }}</p>
|
||||
<button class="btn" @click="save">ذخیره</button>
|
||||
</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.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><input v-model="form.lines[0]" class="input" /></div>
|
||||
<div class="field"><label class="label">خط ۲ عنوان</label><input v-model="form.lines[1]" class="input" /></div>
|
||||
<div class="field"><label class="label">خط ۳ عنوان</label><input v-model="form.lines[2]" class="input" /></div>
|
||||
<div class="field"><label class="label">کلمهٔ برجسته (رنگی)</label><input v-model="form.highlightWord" class="input" /></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>
|
||||
</template>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
<button type="button" class="btn ghost" @click="form.items.push({ n: String(form.items.length + 1).padStart(2, '0'), title: '', cat: '', year: '' })">+ افزودن پروژه</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>
|
||||
</div>
|
||||
<button type="button" class="btn ghost" @click="form.members.push({ name: '', role: '' })">+ افزودن عضو</button>
|
||||
</template>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<template v-else-if="key === 'footer'">
|
||||
<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>
|
||||
<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><textarea v-model="footerAddressText" class="textarea" /></div>
|
||||
<div class="field"><label class="label">شبکههای اجتماعی (هر خط یک مورد)</label><textarea v-model="footerSocialsText" class="textarea" /></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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
hero: "صفحه اول (هیرو)",
|
||||
marquee: "نوار متحرک",
|
||||
manifesto: "معرفی / مانیفست",
|
||||
services: "خدمات",
|
||||
work: "نمونه کارها",
|
||||
process: "فرآیند کار",
|
||||
stats: "آمار",
|
||||
team: "تیم",
|
||||
footer: "تماس و فوتر",
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const key = String(route.params.key);
|
||||
const jsonText = ref("{}");
|
||||
const titleFa = computed(() => TITLES[key] || key);
|
||||
const ready = ref(false);
|
||||
const msg = ref("");
|
||||
const form = reactive<any>({});
|
||||
|
||||
const marqueeText = ref("");
|
||||
const manifestoWordsText = ref("");
|
||||
const footerMarqueeText = ref("");
|
||||
const footerAddressText = ref("");
|
||||
const footerSocialsText = ref("");
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get(`/admin/sections/${key}`);
|
||||
jsonText.value = JSON.stringify(data.data, null, 2);
|
||||
Object.assign(form, structuredClone(data.data));
|
||||
|
||||
if (key === "hero") {
|
||||
form.lines = Array.isArray(form.lines) ? form.lines : ["", "", ""];
|
||||
while (form.lines.length < 3) form.lines.push("");
|
||||
}
|
||||
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 === "process") form.steps = form.steps || [];
|
||||
if (key === "team") form.members = form.members || [];
|
||||
if (key === "footer") {
|
||||
footerMarqueeText.value = (form.marquee || []).join("\n");
|
||||
footerAddressText.value = (form.address || []).join("\n");
|
||||
footerSocialsText.value = (form.socials || []).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) };
|
||||
}
|
||||
if (key === "footer") {
|
||||
return {
|
||||
...form,
|
||||
marquee: footerMarqueeText.value.split("\n").map((s) => s.trim()).filter(Boolean),
|
||||
address: footerAddressText.value.split("\n").map((s) => s.trim()).filter(Boolean),
|
||||
socials: footerSocialsText.value.split("\n").map((s) => s.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
return { ...form };
|
||||
}
|
||||
|
||||
async function save() {
|
||||
msg.value = "";
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText.value);
|
||||
await api.put(`/admin/sections/${key}`, { data: parsed });
|
||||
await api.put(`/admin/sections/${key}`, { data: buildPayload() });
|
||||
msg.value = "ذخیره شد.";
|
||||
} catch {
|
||||
msg.value = "JSON نامعتبر یا خطای سرور.";
|
||||
msg.value = "خطا در ذخیره.";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-box {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.9rem;
|
||||
margin-bottom: 0.9rem;
|
||||
background: #12151b;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>محتوای لندینگ</h1>
|
||||
<p class="muted">هر بخش JSON ساختیافته دارد.</p>
|
||||
<p class="muted">متنها را مثل فرم معمولی ویرایش کنید — نیازی به کدنویسی نیست.</p>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>کلید</th><th>آخرین ویرایش</th><th></th></tr>
|
||||
<tr><th>بخش</th><th>آخرین ویرایش</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in sections" :key="s.id">
|
||||
<td>{{ s.key }}</td>
|
||||
<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>
|
||||
@@ -21,7 +21,22 @@
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } 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;
|
||||
}
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get("/admin/sections");
|
||||
sections.value = data;
|
||||
|
||||
Reference in New Issue
Block a user