浏览代码

Refactor: Modularize Admin Dashboard architecture and stabilize build

unknown 1 天之前
父节点
当前提交
1bc085ba09

+ 3 - 3
package.json

@@ -6,9 +6,9 @@
   "scripts": {
     "dev": "npm run i18n:generate && vite",
     "build": "npm run i18n:generate && vue-tsc && vite build",
-    "i18n:generate": "python3 scripts/manage_locales.py split",
-    "i18n:merge": "python3 scripts/manage_locales.py merge",
-    "i18n:check": "python3 scripts/manage_locales.py missing",
+    "i18n:generate": "python scripts/manage_locales.py split",
+    "i18n:merge": "python scripts/manage_locales.py merge",
+    "i18n:check": "python scripts/manage_locales.py missing",
     "lint": "eslint . --ext ts,vue --report-unused-disable-directives --max-warnings 0",
     "preview": "vite preview",
     "test": "vitest run"

+ 83 - 0
src/components/admin/AuditSection.vue

@@ -0,0 +1,83 @@
+<template>
+  <div class="space-y-6">
+    <div v-if="auditLogs.length === 0" class="flex flex-col items-center justify-center py-20 bg-card/40 border border-border/50 rounded-3xl opacity-50">
+      <History class="w-12 h-12 text-muted-foreground/20 mb-4" />
+      <p class="text-sm text-muted-foreground">{{ t("admin.fields.noAuditLogs") }}</p>
+    </div>
+
+    <div v-else class="bg-card/30 border border-border/50 rounded-3xl overflow-hidden shadow-xl">
+      <div class="overflow-x-auto">
+        <table class="w-full text-left border-collapse">
+          <thead>
+            <tr class="bg-muted/30 border-b border-border/50">
+              <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.date") }}</th>
+              <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.user") }}</th>
+              <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.action") }}</th>
+              <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.target") }}</th>
+              <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.details") }}</th>
+            </tr>
+          </thead>
+          <tbody class="divide-y divide-border/20">
+            <tr v-for="log in auditLogs" :key="log.id" class="hover:bg-white/5 transition-colors">
+              <td class="p-4 text-xs font-mono opacity-80 whitespace-nowrap">
+                {{ formatDateTime(log.created_at) }}
+              </td>
+              <td class="p-4">
+                <div class="flex flex-col">
+                  <span class="text-xs font-bold">{{ log.user_email || 'System' }}</span>
+                  <span class="text-[9px] text-muted-foreground font-mono opacity-50">#{{ log.user_id || '0' }}</span>
+                </div>
+              </td>
+              <td class="p-4">
+                <span :class="[
+                  'px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest border',
+                  log.action.includes('delete') ? 'bg-rose-500/10 text-rose-500 border-rose-500/20' : 
+                  log.action.includes('create') ? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' :
+                  'bg-blue-500/10 text-blue-500 border-blue-500/20'
+                ]">
+                  {{ log.action }}
+                </span>
+              </td>
+              <td class="p-4 text-xs font-bold uppercase tracking-tighter opacity-70">
+                {{ log.target_type }} <span class="text-[9px] font-mono opacity-50 ml-1">#{{ log.target_id }}</span>
+              </td>
+              <td class="p-4">
+                <p class="text-[11px] text-muted-foreground leading-relaxed max-w-xs truncate" :title="log.description">
+                  {{ log.description }}
+                </p>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+
+    <!-- Pagination -->
+    <div v-if="total > 50" class="flex items-center justify-center gap-2 py-4">
+       <button v-for="p in Math.ceil(total / 50)" :key="p" 
+         @click="$emit('update-page', p)" 
+         :class="['w-8 h-8 rounded-lg font-bold text-xs transition-all', currentPage === p ? 'bg-primary text-white shadow-glow' : 'bg-card border border-border/50 text-muted-foreground hover:border-primary/50']">
+         {{ p }}
+       </button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useI18n } from "vue-i18n";
+import { History } from "lucide-vue-next";
+
+const { t } = useI18n();
+
+defineProps<{
+  auditLogs: any[];
+  total: number;
+  currentPage: number;
+}>();
+
+defineEmits(['update-page']);
+
+const formatDateTime = (date: string) => {
+  return new Date(date).toLocaleString();
+};
+</script>

+ 70 - 0
src/components/admin/MaterialsSection.vue

@@ -0,0 +1,70 @@
+<template>
+  <div class="space-y-4">
+    <div v-if="filteredMaterials.length === 0" class="flex flex-col items-center justify-center py-20 bg-card/40 border border-border/50 rounded-3xl opacity-50">
+      <Layers class="w-12 h-12 text-muted-foreground/20 mb-4" />
+      <p class="text-sm text-muted-foreground">{{ t("admin.fields.noMaterials") }}</p>
+    </div>
+
+    <div v-else class="grid gap-4">
+      <div v-for="m in filteredMaterials" :key="m.id"
+        class="p-6 bg-card/40 border border-border/50 rounded-2xl flex items-center justify-between group hover:border-primary/30 transition-all">
+        <div class="flex items-center gap-4">
+          <div :class="['p-3 rounded-xl bg-primary/10 text-primary', !m.is_active && 'opacity-30 grayscale']">
+            <Layers class="w-6 h-6" />
+          </div>
+          <div>
+            <div class="flex items-center gap-2">
+              <h4 class="font-bold">{{ m.name_en }} / {{ m.name_ru }} / {{ m.name_ua }}</h4>
+            </div>
+            <p class="text-xs text-muted-foreground truncate max-w-md">{{ m.desc_en }}</p>
+          </div>
+        </div>
+        <div class="flex items-center gap-8">
+          <div class="text-right">
+            <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">{{ t("admin.fields.pricePerCm3") }}</p>
+            <p class="font-display font-bold text-lg text-primary">{{ m.price_per_cm3 }} EUR</p>
+          </div>
+          <div class="flex items-center gap-2">
+            <button @click="$emit('edit', m)" class="p-2.5 hover:bg-white/5 rounded-xl text-muted-foreground hover:text-primary transition-colors">
+              <Edit2 class="w-4 h-4" />
+            </button>
+            <button @click="$emit('toggle-active', m)" :class="['p-2.5 rounded-xl transition-colors', m.is_active ? 'text-emerald-500 hover:bg-emerald-500/10' : 'text-rose-500 hover:bg-rose-500/10']">
+              <ToggleRight v-if="m.is_active" class="w-6 h-6" />
+              <ToggleLeft v-else class="w-6 h-6" />
+            </button>
+            <button @click="$emit('delete', m.id, m.name_en)" class="p-2.5 hover:bg-rose-500/10 rounded-xl text-muted-foreground hover:text-rose-500 transition-colors">
+              <Trash2 class="w-4 h-4" />
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from "vue";
+import { useI18n } from "vue-i18n";
+import { Layers, Edit2, Trash2, ToggleRight, ToggleLeft } from "lucide-vue-next";
+import Button from "@/components/ui/button.vue";
+
+const { t } = useI18n();
+
+const props = defineProps<{
+  materials: any[];
+  searchQuery: string;
+}>();
+
+defineEmits(['edit', 'delete', 'toggle-active']);
+
+const filteredMaterials = computed(() => {
+  if (!props.searchQuery) return props.materials;
+  const q = props.searchQuery.toLowerCase();
+  return props.materials.filter(m => 
+    m.name_en?.toLowerCase().includes(q) ||
+    m.name_ru?.toLowerCase().includes(q) ||
+    m.name_ua?.toLowerCase().includes(q) ||
+    m.desc_en?.toLowerCase().includes(q)
+  );
+});
+</script>

+ 272 - 0
src/components/admin/OrderCard.vue

@@ -0,0 +1,272 @@
+<template>
+  <div
+    @mouseenter="$emit('focus', order.id)"
+    @mouseleave="$emit('focus', null)"
+    :class="[
+      'group relative bg-card/40 backdrop-blur-md border rounded-3xl overflow-hidden transition-all duration-300',
+      isFocused ? 'border-primary ring-1 ring-primary/20 shadow-glow' : 'border-border/50'
+    ]"
+  >
+    <!-- Paste Indicator -->
+    <div v-if="isFocused" class="absolute top-2 right-2 z-50 pointer-events-none animate-pulse">
+      <div class="px-2 py-1 bg-primary/20 backdrop-blur-md border border-primary/50 rounded-lg flex items-center gap-1.5 shadow-lg">
+        <span class="text-[9px] font-black text-primary uppercase tracking-widest">Ctrl+V — Photo Report</span>
+      </div>
+    </div>
+
+    <div class="flex flex-col lg:flex-row divide-y lg:divide-y-0 lg:divide-x divide-border/50">
+      <!-- Info Column -->
+      <div class="p-6 lg:w-1/2">
+        <div class="flex items-center justify-between mb-4">
+          <span class="text-xl font-black text-foreground bg-primary/10 px-3 py-1 rounded-xl tracking-tight">#{{ order.id }}</span>
+          <span :class="['flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider', statusColor]">
+            <component :is="statusIcon" class="w-3.5 h-3.5" />
+            {{ t("statuses." + order.status) }}
+          </span>
+        </div>
+
+        <div class="flex items-center gap-2">
+          <div class="flex flex-col">
+            <h3 class="font-bold leading-none">{{ order.first_name }} {{ order.last_name }}</h3>
+            <div v-if="order.is_company" class="mt-1 flex flex-col gap-0.5">
+              <span class="text-[9px] font-bold uppercase py-0.5 px-1.5 bg-primary text-primary-foreground rounded-md w-fit">{{ t("auth.fields.company") }}</span>
+              <p class="text-[10px] font-bold text-primary truncate max-w-[150px]">{{ order.company_name }}</p>
+              <p class="text-[8px] text-muted-foreground font-mono">PIB: {{ order.company_pib }}</p>
+            </div>
+            <p v-else class="text-[10px] text-muted-foreground mt-1">{{ order.email }}</p>
+          </div>
+        </div>
+
+        <div class="mt-8 grid grid-cols-2 gap-x-8 gap-y-4 border-t border-border/10 pt-6">
+          <div v-if="order.phone" class="space-y-1">
+            <span class="text-[9px] font-bold uppercase text-muted-foreground tracking-widest">{{ t("admin.fields.phone") }}</span>
+            <p class="text-xs font-medium">{{ order.phone }}</p>
+          </div>
+          <div class="space-y-1">
+            <span class="text-[9px] font-bold uppercase text-muted-foreground tracking-widest">{{ t("admin.labels.registered") }}</span>
+            <p class="text-xs font-medium">{{ formatDate(order.created_at) }}</p>
+          </div>
+          <div class="space-y-1">
+            <span class="text-[9px] font-bold uppercase text-muted-foreground tracking-widest">{{ t("admin.fields.address") }}</span>
+            <p class="text-xs font-medium leading-relaxed">{{ order.shipping_address || '—' }}</p>
+          </div>
+          <div class="space-y-1">
+             <span class="text-[9px] font-bold uppercase text-muted-foreground tracking-widest">{{ t("admin.fields.deliveryType") }}</span>
+             <p class="text-xs font-medium">{{ order.delivery_type === 'cargo' ? t("admin.fields.cargo") : t("admin.fields.pickup") }}</p>
+          </div>
+        </div>
+
+        <div v-if="order.notes" class="mt-8 p-4 bg-primary/5 border border-primary/10 rounded-2xl italic">
+          <span class="text-[9px] font-bold uppercase text-primary/60 mb-2 block tracking-widest">{{ t("admin.fields.projectNotes") }}</span>
+          <p class="text-[11px] leading-relaxed">"{{ order.notes }}"</p>
+        </div>
+      </div>
+
+      <!-- Resources Column -->
+      <div class="p-6 lg:w-1/4 border-x border-border/50">
+        <div class="flex items-center justify-between mb-4">
+          <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.sourceFiles") }} ({{ order.files?.length || 0 }})</span>
+          <label class="p-1.5 bg-blue-500/10 text-blue-500 rounded-lg cursor-pointer hover:bg-blue-500 hover:text-white transition-all">
+            <Plus class="w-3.5 h-3.5" />
+            <input type="file" class="hidden" accept=".stl,.obj" @change="e => $emit('attach-file', order.id, (e.target as HTMLInputElement).files?.[0])" />
+          </label>
+        </div>
+
+        <div v-if="order.model_link" class="mb-6 p-3 bg-blue-500/5 border border-blue-500/20 rounded-xl">
+          <span class="text-[9px] font-bold uppercase text-blue-500/60 mb-1 block">{{ t("admin.fields.externalLink") }}</span>
+          <div class="flex items-center justify-between gap-2 overflow-hidden">
+            <p class="text-[10px] text-muted-foreground truncate">{{ order.model_link }}</p>
+            <a :href="order.model_link" target="_blank" class="text-blue-500 hover:underline"><ExternalLink class="w-3.5 h-3.5" /></a>
+          </div>
+        </div>
+
+        <div class="space-y-3">
+          <template v-for="(f, i) in order.files" :key="f.id || i">
+            <div v-if="f.id" class="relative group/file bg-background/30 border border-border/50 rounded-2xl overflow-hidden hover:border-primary/30 transition-all flex h-20">
+              <div class="w-20 bg-muted/20 flex items-center justify-center border-r border-border/50 overflow-hidden">
+                <img v-if="f.preview_path" :src="`${resourcesBaseUrl}/${f.preview_path}`" class="w-full h-full object-contain p-1" />
+                <FileBox v-else class="w-6 h-6 text-muted-foreground/30" />
+                <div class="absolute inset-0 bg-primary/20 opacity-0 group-hover/file:opacity-100 transition-opacity flex items-center justify-center">
+                  <a :href="`${resourcesBaseUrl}/${f.file_path}`" target="_blank" class="bg-card w-8 h-8 rounded-full flex items-center justify-center shadow-lg"><Download class="w-4 h-4 text-primary" /></a>
+                </div>
+              </div>
+              <div class="flex-1 p-3 flex flex-col justify-center min-w-0">
+                <p class="text-[11px] font-bold truncate mb-1 pr-8">{{ f.filename }}</p>
+                <div class="flex flex-wrap gap-2 items-center">
+                  <span v-if="f.file_size" class="text-[9px] text-muted-foreground uppercase opacity-60">{{ (f.file_size / 1024 / 1024).toFixed(1) }} MB</span>
+                  <div v-if="f.print_time || f.filament_g" class="flex gap-2 border-l border-border/50 pl-2">
+                    <span v-if="f.print_time" class="text-[9px] font-bold text-primary/80">⏱️ {{ f.print_time }}</span>
+                    <span v-if="f.filament_g" class="text-[9px] font-bold text-primary/80">⚖️ {{ f.filament_g.toFixed(1) }}g</span>
+                  </div>
+                </div>
+              </div>
+              <div class="absolute top-2 right-2 flex flex-col items-end gap-1">
+                <div class="px-1.5 py-0.5 bg-background/80 backdrop-blur-md rounded-md border border-border/50 text-[10px] font-bold">x{{ f.quantity || 1 }}</div>
+                <button @click.prevent="$emit('delete-file', order.id, f.id, f.filename)" class="p-1 bg-background/80 backdrop-blur-md border border-border/50 text-rose-500 hover:bg-rose-500 hover:text-white rounded-md transition-colors shadow-sm">
+                  <Trash2 class="w-2.5 h-2.5" />
+                </button>
+              </div>
+            </div>
+          </template>
+        </div>
+
+        <div class="mt-8 pt-6 border-t border-border/50">
+          <div class="flex items-center justify-between mb-4">
+            <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.photoReport") }} ({{ order.photos?.length || 0 }})</span>
+            <label class="p-1.5 bg-primary/10 text-primary rounded-lg cursor-pointer hover:bg-primary hover:text-primary-foreground transition-all">
+              <Plus class="w-3.5 h-3.5" />
+              <input type="file" class="hidden" accept="image/*" @change="e => $emit('upload-photo', order.id, (e.target as HTMLInputElement).files?.[0])" />
+            </label>
+          </div>
+          <div class="flex flex-wrap gap-2">
+            <div v-for="(p, i) in order.photos" :key="i" class="relative group/img overflow-hidden rounded-lg border border-border/50 w-12 h-12 bg-background/50">
+              <img :src="`${resourcesBaseUrl}/${p.file_path}`" class="w-full h-full object-cover" />
+              <button @click="$emit('toggle-photo-public', p.id, p.is_public)" :class="`absolute top-0 right-0 p-0.5 rounded-bl-md bg-black/60 z-10 transition-colors ${p.is_public ? 'text-blue-400 hover:text-blue-300' : 'text-gray-400 hover:text-white'}`">
+                <Eye v-if="p.is_public" class="w-2.5 h-2.5" /><EyeOff v-else class="w-2.5 h-2.5" />
+              </button>
+              <div class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover/img:opacity-100 transition-opacity gap-2">
+                 <a :href="`${resourcesBaseUrl}/${p.file_path}`" target="_blank" class="w-7 h-7 bg-white/10 hover:bg-white/20 rounded-full flex items-center justify-center transition-colors">
+                    <ExternalLink class="w-3.5 h-3.5 text-white" />
+                 </a>
+                 <button @click="$emit('delete-photo', p.id)" class="w-7 h-7 bg-rose-500/20 hover:bg-rose-500/40 rounded-full flex items-center justify-center transition-colors">
+                    <Trash2 class="w-3.5 h-3.5 text-white" />
+                 </button>
+              </div>
+            </div>
+            <div v-if="!order.photos?.length" class="w-full py-6 border border-dashed border-border/50 rounded-2xl flex flex-col items-center justify-center opacity-40">
+              <ImageIcon class="w-6 h-6 mb-2" /><span class="text-[10px] font-bold uppercase tracking-tighter">{{ t("admin.fields.noPhotos") }}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- Pricing & Actions Column -->
+      <div class="p-6 lg:w-1/4 bg-primary/5">
+        <div class="flex items-center gap-2 mb-6">
+          <input type="checkbox" :id="`notify-${order.id}`" v-model="internalNotify" @change="$emit('update-notify', order.id, internalNotify)" class="w-4 h-4 rounded border-border" />
+          <label :for="`notify-${order.id}`" class="text-[10px] font-bold uppercase text-muted-foreground cursor-pointer">{{ t("admin.fields.notifyUser") }}</label>
+        </div>
+
+        <div class="grid grid-cols-2 gap-2 mb-8">
+          <button v-for="s in statusOptions" :key="s"
+            @click="$emit('update-status', order.id, s)"
+            :class="`text-[9px] font-bold uppercase py-2 rounded-xl border transition-all ${order.status === s ? 'bg-primary text-primary-foreground border-primary shadow-glow' : 'bg-background hover:border-primary/30 border-border/50'}`">
+            {{ t("statuses." + s) }}
+          </button>
+        </div>
+
+        <div class="space-y-4 mb-8">
+           <div class="flex justify-between items-baseline">
+             <span class="text-[10px] font-bold uppercase text-muted-foreground">{{ t("admin.fields.totalPrice") }}</span>
+             <p class="text-2xl font-black font-display text-primary">{{ order.invoice_amount || 0 }} EUR</p>
+           </div>
+           
+           <div class="pt-4 border-t border-primary/10 space-y-3">
+              <div class="flex items-center justify-between text-[10px] font-bold uppercase text-muted-foreground tracking-tighter">
+                <span>{{ t("admin.labels.fiscalization") }}</span>
+                <span :class="order.fiscal_jikr ? 'text-emerald-500' : 'text-rose-500' ">{{ order.fiscal_jikr ? t("admin.fields.active") : t("admin.fields.notActive") }}</span>
+              </div>
+              <div class="grid grid-cols-2 gap-3">
+                 <div class="space-y-1">
+                   <label class="text-[9px] font-bold uppercase text-muted-foreground ml-1">IKOF</label>
+                   <input v-model="fiscalData.ikof" @change="$emit('update-fiscal', order.id, fiscalData)" class="w-full bg-background border border-border/50 rounded-lg px-2 py-1.5 text-[10px] font-mono outline-none" />
+                 </div>
+                 <div class="space-y-1">
+                   <label class="text-[9px] font-bold uppercase text-muted-foreground ml-1">JIKR</label>
+                   <input v-model="fiscalData.jikr" @change="$emit('update-fiscal', order.id, fiscalData)" class="w-full bg-background border border-border/50 rounded-lg px-2 py-1.5 text-[10px] font-mono outline-none" />
+                 </div>
+              </div>
+           </div>
+        </div>
+
+        <div class="mt-auto space-y-3">
+          <Button variant="hero" class="w-full gap-2 rounded-2xl h-12" @click="$emit('open-chat', order.id)">
+            <MessageCircle class="w-4 h-4" />{{ t("admin.actions.chatWithClient") }}
+          </Button>
+          <div class="pt-4 border-t border-rose-500/10">
+            <button @click="$emit('delete-order', order.id)" class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-rose-500/5 hover:bg-rose-500 text-rose-500 hover:text-white border border-transparent font-bold transition-all text-xs group">
+              <Trash2 class="w-4 h-4 transition-transform group-hover:scale-110" /> {{ t("admin.actions.deleteOrder") }}
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- Feedback Row -->
+    <div v-if="order.review_text" class="p-6 bg-amber-500/5 border-t border-border/50 flex flex-col sm:flex-row items-center justify-between gap-6 relative z-10">
+      <div class="flex items-start gap-4 flex-1">
+        <div class="pt-1">
+          <div class="flex gap-0.5">
+            <Star v-for="i in 5" :key="i" class="w-4 h-4" :class="order.rating >= i ? 'text-yellow-500 fill-yellow-500' : 'text-muted-foreground/30'" />
+          </div>
+        </div>
+        <div>
+          <p class="text-xs font-bold text-foreground italic leading-relaxed">"{{ order.review_text }}"</p>
+          <div class="flex items-center gap-3 mt-2">
+            <span class="text-[9px] font-black uppercase tracking-widest text-muted-foreground shadow-sm">Customer Feedback</span>
+            <span v-if="order.review_approved" class="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest text-emerald-500">
+              <CheckCircle2 class="w-3.5 h-3.5" /> Approved
+            </span>
+            <span v-else class="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest text-amber-500">
+              <Clock class="w-3.5 h-3.5" /> Pending
+            </span>
+          </div>
+        </div>
+      </div>
+      <Button v-if="!order.review_approved" variant="hero" class="whitespace-nowrap rounded-xl" @click="$emit('approve-review', order.id)">
+        Approve Entry
+      </Button>
+    </div>
+
+    <!-- Chat Panel -->
+    <div v-if="isAdminChatOpen" :id="'admin-chat-' + order.id" class="border-t border-border/50">
+      <OrderChat :orderId="order.id" @close="$emit('close-chat')" closable />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, reactive } from "vue";
+import { useI18n } from "vue-i18n";
+import { 
+  Clock, CheckCircle2, Truck, XCircle, AlertCircle, RefreshCw, 
+  FileText, ExternalLink, ShieldCheck, Eye, Trash2, ImageIcon, 
+  EyeOff, MessageCircle, FileBox, Download, Star, Plus 
+} from "lucide-vue-next";
+import Button from "@/components/ui/button.vue";
+import OrderChat from "@/components/OrderChat.vue";
+
+const { t } = useI18n();
+
+const props = defineProps<{
+  order: any;
+  statusConfig: Record<string, any>;
+  resourcesBaseUrl: string;
+  isFocused: boolean;
+  isAdminChatOpen: boolean;
+  notifyStatus: boolean;
+  fiscalData: { fiscal_qr_url: string; ikof: string; jikr: string };
+  adminChatId?: any;
+}>();
+
+const emit = defineEmits([
+  'focus', 'update-status', 'delete-order', 'attach-file', 'upload-photo',
+  'delete-file', 'delete-photo', 'toggle-photo-public', 'approve-review',
+  'open-chat', 'close-chat', 'update-notify', 'update-fiscal'
+]);
+
+const internalNotify = ref(props.notifyStatus);
+const fiscalData = reactive({ ...props.fiscalData });
+
+watch(() => props.notifyStatus, (val) => { internalNotify.value = val; });
+watch(() => props.fiscalData, (val) => { Object.assign(fiscalData, val); }, { deep: true });
+
+const statusOptions = Object.keys(props.statusConfig);
+
+const statusColor = props.statusConfig[props.order.status]?.color || "bg-muted text-muted-foreground";
+const statusIcon = props.statusConfig[props.order.status]?.icon || Clock;
+
+const formatDate = (date: string) => {
+  return new Date(date).toLocaleDateString();
+};
+</script>

+ 133 - 0
src/components/admin/OrdersSection.vue

@@ -0,0 +1,133 @@
+<template>
+  <div class="space-y-8">
+    <!-- Filter bar -->
+    <div class="flex flex-wrap items-center gap-4 bg-card/30 p-4 rounded-2xl border border-border/50">
+      <div class="flex items-center gap-2">
+        <Filter class="w-4 h-4 text-muted-foreground" />
+        <span class="text-xs font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.filters") }}</span>
+      </div>
+      
+      <select v-model="statusFilter" class="bg-background border border-border/50 rounded-xl px-4 py-2 text-xs min-w-[140px] focus:ring-2 ring-primary/20 outline-none">
+        <option value="all">{{ t("admin.allStatuses") }}</option>
+        <option v-for="s in statusOptions" :key="s" :value="s">{{ t("statuses." + s) }}</option>
+      </select>
+
+      <div class="flex items-center gap-2 bg-background border border-border/50 rounded-xl px-3 py-1.5">
+         <span class="text-[10px] font-bold text-muted-foreground uppercase mr-1">{{ t("admin.from") }}</span>
+         <input type="date" v-model="dateFrom" class="bg-transparent border-none text-xs focus:ring-0 p-0" />
+      </div>
+
+      <div class="flex items-center gap-2 bg-background border border-border/50 rounded-xl px-3 py-1.5">
+         <span class="text-[10px] font-bold text-muted-foreground uppercase mr-1">{{ t("admin.to") }}</span>
+         <input type="date" v-model="dateTo" class="bg-transparent border-none text-xs focus:ring-0 p-0" />
+      </div>
+
+      <button @click="resetFilters" class="text-xs text-muted-foreground hover:text-primary transition-colors underline ml-auto">{{ t("admin.reset") }}</button>
+    </div>
+
+    <!-- Orders Grid -->
+    <div v-if="filtered.length > 0" class="grid gap-6">
+      <OrderCard
+        v-for="order in filtered"
+        :key="order.id"
+        :order="order"
+        :statusConfig="statusConfig"
+        :resourcesBaseUrl="resourcesBaseUrl"
+        :isFocused="focusedOrderId === order.id"
+        :isAdminChatOpen="adminChatId === order.id"
+        :notifyStatus="notifyStatusMap[order.id]"
+        :fiscalData="fiscalFormMap[order.id]"
+        @focus="id => focusedOrderId = id"
+        @update-status="(id, s) => $emit('update-status', id, s)"
+        @delete-order="id => $emit('delete-order', id)"
+        @attach-file="(id, f) => $emit('attach-file', id, f)"
+        @upload-photo="(id, f) => $emit('upload-photo', id, f)"
+        @delete-file="(id, fid, fname) => $emit('delete-file', id, fid, fname)"
+        @delete-photo="pid => $emit('delete-photo', pid)"
+        @toggle-photo-public="(pid, pub) => $emit('toggle-photo-public', pid, pub)"
+        @approve-review="id => $emit('approve-review', id)"
+        @open-chat="id => $emit('open-chat', id)"
+        @close-chat="$emit('close-chat')"
+        @update-notify="(id, val) => $emit('update-notify', id, val)"
+        @update-fiscal="(id, data) => $emit('update-fiscal', id, data)"
+      />
+    </div>
+
+    <!-- No results -->
+    <div v-else class="flex flex-col items-center justify-center py-20 bg-card/40 border border-border/50 rounded-3xl opacity-50">
+      <Package class="w-12 h-12 text-muted-foreground/20 mb-4" />
+      <p class="text-sm text-muted-foreground">{{ t("admin.noOrdersFound") }}</p>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from "vue";
+import { useI18n } from "vue-i18n";
+import { Search, Filter, RefreshCw, Plus, Package } from "lucide-vue-next";
+import Button from "@/components/ui/button.vue";
+import OrderCard from "./OrderCard.vue";
+
+const { t } = useI18n();
+
+const props = defineProps<{
+  orders: any[];
+  statusConfig: Record<string, any>;
+  resourcesBaseUrl: string;
+  adminChatId: any;
+  notifyStatusMap: Record<number, boolean>;
+  fiscalFormMap: Record<number, any>;
+  searchQuery: string;
+}>();
+
+const emit = defineEmits([
+  'update-status', 'delete-order', 'attach-file', 'upload-photo', 
+  'delete-file', 'delete-photo', 'toggle-photo-public', 
+  'approve-review', 'open-chat', 'close-chat', 
+  'update-notify', 'update-fiscal'
+]);
+
+const statusFilter = ref("all");
+const dateFrom = ref("");
+const dateTo = ref("");
+const focusedOrderId = ref<number | null>(null);
+
+const statusOptions = Object.keys(props.statusConfig);
+
+const filtered = computed(() => {
+  let list = [...props.orders];
+  
+  if (statusFilter.value !== "all") {
+    list = list.filter(o => o.status === statusFilter.value);
+  }
+  
+  if (dateFrom.value) {
+    const from = new Date(dateFrom.value);
+    list = list.filter(o => new Date(o.created_at) >= from);
+  }
+  
+  if (dateTo.value) {
+    const to = new Date(dateTo.value);
+    to.setHours(23, 59, 59);
+    list = list.filter(o => new Date(o.created_at) <= to);
+  }
+
+  if (props.searchQuery) {
+    const q = props.searchQuery.toLowerCase();
+    list = list.filter(o => 
+      o.id.toString().includes(q) ||
+      o.first_name?.toLowerCase().includes(q) ||
+      o.last_name?.toLowerCase().includes(q) ||
+      o.email?.toLowerCase().includes(q)
+    );
+  }
+
+  return list.sort((a, b) => b.id - a.id);
+});
+
+const resetFilters = () => {
+  statusFilter.value = 'all';
+  dateFrom.value = '';
+  dateTo.value = '';
+};
+</script>

+ 34 - 0
src/components/admin/PortfolioSection.vue

@@ -0,0 +1,34 @@
+<template>
+  <div class="space-y-4">
+    <div v-if="portfolioItems.length === 0" class="flex flex-col items-center justify-center py-20 bg-card/40 border border-border/50 rounded-3xl opacity-50">
+      <ImageIcon class="w-12 h-12 text-muted-foreground/20 mb-4" />
+      <p class="text-sm text-muted-foreground">{{ t("admin.fields.noPortfolio") }}</p>
+    </div>
+
+    <div v-else class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
+      <div v-for="pi in portfolioItems" :key="pi.id" class="group relative aspect-square bg-card/40 border border-border/50 rounded-2xl overflow-hidden hover:border-primary/30 transition-all shadow-md">
+        <img :src="`${resourcesBaseUrl}/${pi.file_path}`" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
+        <div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-all flex flex-col items-center justify-center p-4 text-center">
+            <button @click="$emit('delete', pi.id)" class="w-10 h-10 bg-rose-500/20 hover:bg-rose-500 text-white rounded-full flex items-center justify-center transition-all mb-3 border border-rose-500/30">
+              <Trash2 class="w-5 h-5" />
+            </button>
+            <p v-if="pi.material_name" class="text-[9px] font-bold text-primary uppercase tracking-widest bg-primary/10 px-2 py-1 rounded-md">{{ pi.material_name }}</p>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useI18n } from "vue-i18n";
+import { Image as ImageIcon, Trash2 } from "lucide-vue-next";
+
+const { t } = useI18n();
+
+defineProps<{
+  portfolioItems: any[];
+  resourcesBaseUrl: string;
+}>();
+
+defineEmits(['delete']);
+</script>

+ 66 - 0
src/components/admin/PostsSection.vue

@@ -0,0 +1,66 @@
+<template>
+  <div class="space-y-4">
+    <div v-if="filteredPosts.length === 0" class="flex flex-col items-center justify-center py-20 bg-card/40 border border-border/50 rounded-3xl opacity-50">
+      <Newspaper class="w-12 h-12 text-muted-foreground/20 mb-4" />
+      <p class="text-sm text-muted-foreground">{{ t("admin.fields.noPosts") }}</p>
+    </div>
+
+    <div v-else class="grid gap-4">
+      <div v-for="p in filteredPosts" :key="p.id"
+        class="p-4 bg-card/40 border border-border/50 rounded-2xl flex items-center justify-between group hover:border-primary/30 transition-all">
+        <div class="flex items-center gap-4 min-w-0">
+          <div class="w-16 h-16 rounded-xl bg-muted/20 overflow-hidden flex-shrink-0 border border-border/50">
+            <img v-if="p.image_url" :src="p.image_url" class="w-full h-full object-cover" />
+            <Newspaper v-else class="w-full h-full p-4 text-muted-foreground/30" />
+          </div>
+          <div class="min-w-0">
+            <h4 class="font-bold truncate text-sm">{{ p.title_en }}</h4>
+            <div class="flex items-center gap-3 text-[10px] text-muted-foreground uppercase font-bold mt-1">
+              <span class="bg-muted px-1.5 py-0.5 rounded border border-border/50">{{ p.category }}</span>
+              <span class="opacity-30">•</span>
+              <span class="font-mono">{{ p.slug }}</span>
+            </div>
+          </div>
+        </div>
+        <div class="flex items-center gap-2">
+          <button @click="$emit('edit', p)" class="p-2.5 rounded-xl hover:bg-white/5 text-muted-foreground hover:text-primary transition-all">
+            <Edit2 class="w-4 h-4" />
+          </button>
+          <button @click="$emit('toggle-publish', p)" :class="`p-2.5 rounded-xl transition-all ${p.is_published ? 'text-emerald-500 hover:bg-emerald-500/10' : 'text-rose-500 hover:bg-rose-500/10'}`">
+            <Eye v-if="p.is_published" class="w-5 h-5" />
+            <EyeOff v-else class="w-5 h-5" />
+          </button>
+          <button @click="$emit('delete', p.id, p.title_en)" class="p-2.5 rounded-xl hover:bg-rose-500/10 text-muted-foreground hover:text-rose-500 transition-all">
+            <Trash2 class="w-4 h-4" />
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from "vue";
+import { useI18n } from "vue-i18n";
+import { Newspaper, Edit2, Trash2, Eye, EyeOff } from "lucide-vue-next";
+import Button from "@/components/ui/button.vue";
+
+const { t } = useI18n();
+
+const props = defineProps<{
+  posts: any[];
+  searchQuery: string;
+}>();
+
+defineEmits(['edit', 'delete', 'toggle-publish']);
+
+const filteredPosts = computed(() => {
+  if (!props.searchQuery) return props.posts;
+  const q = props.searchQuery.toLowerCase();
+  return props.posts.filter(p => 
+    p.title_en?.toLowerCase().includes(q) || 
+    p.title_ru?.toLowerCase().includes(q) ||
+    p.slug?.toLowerCase().includes(q)
+  );
+});
+</script>

+ 66 - 0
src/components/admin/ServicesSection.vue

@@ -0,0 +1,66 @@
+<template>
+  <div class="space-y-4">
+    <div v-if="filteredServices.length === 0" class="flex flex-col items-center justify-center py-20 bg-card/40 border border-border/50 rounded-3xl opacity-50">
+      <Database class="w-12 h-12 text-muted-foreground/20 mb-4" />
+      <p class="text-sm text-muted-foreground">{{ t("admin.fields.noServices") }}</p>
+    </div>
+
+    <div v-else class="grid gap-4">
+      <div v-for="s in filteredServices" :key="s.id"
+        class="p-6 bg-card/40 border border-border/50 rounded-2xl flex items-center justify-between group hover:border-primary/30 transition-all">
+        <div class="flex items-center gap-4">
+          <div :class="['p-3 rounded-xl bg-blue-500/10 text-blue-500', !s.is_active && 'opacity-30 grayscale']">
+            <Database class="w-6 h-6" />
+          </div>
+          <div>
+            <div class="flex items-center gap-2">
+              <h4 class="font-bold">{{ s.name_en }} / {{ s.name_ru }} / {{ s.name_ua }}</h4>
+              <span class="text-[10px] px-2 py-0.5 rounded-full bg-white/5 text-muted-foreground font-bold border border-border/50 uppercase">{{ s.tech_type }}</span>
+            </div>
+            <p class="text-xs text-muted-foreground truncate max-w-md">{{ s.desc_en }}</p>
+          </div>
+        </div>
+        <div class="flex items-center gap-2">
+          <button @click="$emit('edit', s)" class="p-2.5 hover:bg-white/5 rounded-xl text-muted-foreground hover:text-primary transition-colors">
+            <Edit2 class="w-4 h-4" />
+          </button>
+          <button @click="$emit('toggle-active', s)" :class="['p-2.5 rounded-xl transition-colors', s.is_active ? 'text-emerald-500 hover:bg-emerald-500/10' : 'text-rose-500 hover:bg-rose-500/10']">
+            <ToggleRight v-if="s.is_active" class="w-6 h-6" />
+            <ToggleLeft v-else class="w-6 h-6" />
+          </button>
+          <button @click="$emit('delete', s.id, s.name_en)" class="p-2.5 hover:bg-rose-500/10 rounded-xl text-muted-foreground hover:text-rose-500 transition-colors">
+            <Trash2 class="w-4 h-4" />
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from "vue";
+import { useI18n } from "vue-i18n";
+import { Database, Edit2, Trash2, ToggleRight, ToggleLeft } from "lucide-vue-next";
+import Button from "@/components/ui/button.vue";
+
+const { t } = useI18n();
+
+const props = defineProps<{
+  services: any[];
+  searchQuery: string;
+}>();
+
+defineEmits(['edit', 'delete', 'toggle-active']);
+
+const filteredServices = computed(() => {
+  if (!props.searchQuery) return props.services;
+  const q = props.searchQuery.toLowerCase();
+  return props.services.filter(s => 
+    s.name_en?.toLowerCase().includes(q) ||
+    s.name_ru?.toLowerCase().includes(q) ||
+    s.name_ua?.toLowerCase().includes(q) ||
+    s.tech_type?.toLowerCase().includes(q) ||
+    s.desc_en?.toLowerCase().includes(q)
+  );
+});
+</script>

+ 127 - 0
src/components/admin/UsersSection.vue

@@ -0,0 +1,127 @@
+<template>
+  <div class="space-y-6">
+    <!-- User Search and Summary -->
+    <div class="flex items-center gap-4 bg-card/40 p-4 rounded-2xl border border-border/50">
+      <div class="relative flex-1">
+        <Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
+        <input 
+          :value="searchQuery" 
+          @input="e => $emit('update:searchQuery', (e.target as HTMLInputElement).value)"
+          :placeholder="t('admin.searchUsersPlaceholder')" 
+          class="w-full bg-background/50 border border-border/50 rounded-xl pl-10 pr-4 py-2.5 text-sm focus:ring-2 ring-primary/20 outline-none transition-all" 
+        />
+      </div>
+      <div class="text-xs font-bold text-muted-foreground uppercase tracking-widest bg-muted/30 px-4 py-2 rounded-xl border border-border/50">
+        {{ t("admin.total") }}: {{ total }}
+      </div>
+    </div>
+
+    <!-- Users Table -->
+    <div v-if="users.length === 0" class="flex flex-col items-center justify-center py-20 bg-card/40 border border-border/50 rounded-3xl opacity-50">
+      <Users class="w-12 h-12 text-muted-foreground/20 mb-4" />
+      <p class="text-sm text-muted-foreground">{{ t("admin.fields.noUsers") }}</p>
+    </div>
+
+    <div v-else class="bg-card/40 backdrop-blur-md border border-border/50 rounded-3xl overflow-hidden shadow-xl">
+      <div class="overflow-x-auto">
+        <table class="w-full text-left border-collapse">
+          <thead>
+            <tr class="bg-muted/30 border-b border-border/50">
+              <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.user") }}</th>
+              <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.contact") }}</th>
+              <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("auth.fields.accountType") }}</th>
+              <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.role") }}</th>
+              <th class="p-4 text-[10px] font-bold uppercase tracking-wider text-center">{{ t("admin.labels.chat") }}</th>
+              <th class="p-4 text-[10px] font-bold uppercase tracking-wider text-center">{{ t("admin.fields.active") }}</th>
+              <th class="p-4 text-[10px] font-bold uppercase tracking-wider">{{ t("admin.labels.registered") }}</th>
+              <th class="p-4 text-[10px] font-bold uppercase tracking-wider text-right">{{ t("admin.labels.actions") }}</th>
+            </tr>
+          </thead>
+          <tbody class="divide-y divide-border/30">
+            <tr v-for="u in users" :key="u.id" class="hover:bg-primary/5 transition-colors group/row">
+              <td class="p-4">
+                <div class="flex flex-col">
+                  <span class="text-sm font-bold">{{ u.first_name }} {{ u.last_name }}</span>
+                  <span class="text-[10px] text-muted-foreground font-mono opacity-50">ID: {{ u.id }}</span>
+                </div>
+              </td>
+              <td class="p-4 text-xs">
+                <div class="flex flex-col">
+                  <span class="font-bold">{{ u.email }}</span>
+                  <span class="text-muted-foreground opacity-70">{{ u.phone }}</span>
+                </div>
+              </td>
+              <td class="p-4">
+                <span :class="`px-2 py-0.5 rounded-full text-[9px] font-bold uppercase ${u.is_company ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground border border-border/50'}`">
+                  {{ u.is_company ? t("auth.fields.company") : t("auth.fields.individual") }}
+                </span>
+                <p v-if="u.is_company" class="text-[9px] font-bold mt-1 text-primary truncate max-w-[120px]">{{ u.company_name }}</p>
+              </td>
+              <td class="p-4">
+                <span :class="`px-2 py-0.5 rounded-full text-[9px] font-bold uppercase ${u.role === 'admin' ? 'bg-rose-500/10 text-rose-500 border border-rose-500/20' : 'bg-muted text-muted-foreground border border-border/50'}`">
+                  {{ u.role }}
+                </span>
+              </td>
+              <td class="p-4 text-center">
+                <button @click="$emit('toggle-chat', u.id, u.can_chat)" class="inline-flex hover:scale-110 transition-transform">
+                  <component :is="u.can_chat ? ToggleRight : ToggleLeft" class="w-6 h-6" :class="u.can_chat ? 'text-emerald-500' : 'text-muted-foreground'" />
+                </button>
+              </td>
+              <td class="p-4 text-center">
+                <button @click="$emit('toggle-active', u.id, u.is_active)" class="inline-flex hover:scale-110 transition-transform">
+                  <component :is="u.is_active ? ToggleRight : ToggleLeft" class="w-6 h-6" :class="u.is_active ? 'text-emerald-500' : 'text-rose-500'" />
+                </button>
+              </td>
+              <td class="p-4 text-[10px] text-muted-foreground font-bold uppercase">
+                {{ new Date(u.created_at).toLocaleDateString() }}
+              </td>
+              <td class="p-4 text-right">
+                <div class="flex items-center justify-end gap-1">
+                  <button @click="$emit('reset-password', u.id)" class="p-2 rounded-lg hover:bg-white/10 text-muted-foreground hover:text-primary transition-all" :title="t('admin.actions.resetPassword')">
+                    <Key class="w-4 h-4" />
+                  </button>
+                  <button @click="$emit('toggle-role', u.id, u.role === 'admin' ? 'user' : 'admin')" class="p-2 rounded-lg hover:bg-white/10 text-muted-foreground hover:text-primary transition-all" :title="t('admin.actions.toggleAdminRole')">
+                    <ShieldCheck class="w-4 h-4" />
+                  </button>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+    
+    <!-- Pagination -->
+    <div v-if="total > 50" class="flex items-center justify-center gap-2 py-4">
+       <button v-for="p in Math.ceil(total / 50)" :key="p" 
+         @click="$emit('update-page', p)" 
+         :class="['w-8 h-8 rounded-lg font-bold text-xs transition-all', currentPage === p ? 'bg-primary text-white shadow-glow' : 'bg-card border border-border/50 text-muted-foreground hover:border-primary/50']">
+         {{ p }}
+       </button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useI18n } from "vue-i18n";
+import { Search, Users, ToggleRight, ToggleLeft, Key, ShieldCheck } from "lucide-vue-next";
+import Button from "@/components/ui/button.vue";
+
+const { t } = useI18n();
+
+defineProps<{
+  users: any[];
+  total: number;
+  currentPage: number;
+  searchQuery: string;
+}>();
+
+const emit = defineEmits([
+  'update:searchQuery', 'toggle-chat', 'toggle-active', 
+  'reset-password', 'toggle-role', 'update-page'
+]);
+
+const onSearch = (e: Event) => {
+  emit('update:searchQuery', (e.target as HTMLInputElement).value);
+};
+</script>

文件差异内容过多而无法显示
+ 140 - 878
src/pages/Admin.vue


部分文件因为文件数量过多而无法显示