wahnsinn vibe
This commit is contained in:
12
frontend/admin/index.html
Normal file
12
frontend/admin/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
frontend/admin/package.json
Normal file
28
frontend/admin/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@shop/admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@shop/shared": "workspace:*",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"axios": "^1.7.7",
|
||||
"pinia": "^2.2.4",
|
||||
"vue": "^3.5.11",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11",
|
||||
"vue-tsc": "^2.1.8"
|
||||
}
|
||||
}
|
||||
6
frontend/admin/postcss.config.js
Normal file
6
frontend/admin/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
64
frontend/admin/src/App.vue
Normal file
64
frontend/admin/src/App.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
|
||||
import HealthBadge from "./components/HealthBadge.vue";
|
||||
import { useAuth } from "./stores/auth";
|
||||
|
||||
const auth = useAuth();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.fetchMe();
|
||||
if (!auth.isAdmin && route.path !== "/login") {
|
||||
router.push("/login");
|
||||
}
|
||||
});
|
||||
|
||||
function logout() {
|
||||
auth.logout();
|
||||
router.push("/login");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="route.path === '/login'" class="min-h-screen flex items-center justify-center">
|
||||
<RouterView />
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-screen flex">
|
||||
<aside class="w-56 bg-admin-700 text-white flex flex-col">
|
||||
<div class="p-4 text-lg font-bold border-b border-admin-600">Shop Admin</div>
|
||||
<nav class="flex-1 p-2 space-y-1 text-sm">
|
||||
<RouterLink to="/" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
|
||||
🏠 Dashboard
|
||||
</RouterLink>
|
||||
<RouterLink to="/products" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
|
||||
📦 Produkte
|
||||
</RouterLink>
|
||||
<RouterLink to="/categories" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
|
||||
🏷 Kategorien
|
||||
</RouterLink>
|
||||
<RouterLink to="/orders" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
|
||||
🛒 Bestellungen
|
||||
</RouterLink>
|
||||
<RouterLink to="/settings" class="block px-3 py-2 rounded hover:bg-admin-600" active-class="bg-admin-600">
|
||||
⚙ Einstellungen
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<div class="p-3 border-t border-admin-600 text-xs">
|
||||
<div class="mb-1">{{ auth.user?.email }}</div>
|
||||
<button @click="logout" class="underline">Abmelden</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="flex-1 overflow-auto">
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between">
|
||||
<div class="text-sm text-gray-500">Admin-Panel</div>
|
||||
<HealthBadge />
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<RouterView />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
2
frontend/admin/src/api.ts
Normal file
2
frontend/admin/src/api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { createApi } from "@shop/shared/api";
|
||||
export const api = createApi("");
|
||||
137
frontend/admin/src/components/AIChatBox.vue
Normal file
137
frontend/admin/src/components/AIChatBox.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProposalCard as ProposalCardType } from "@shop/shared/types";
|
||||
import { computed, ref } from "vue";
|
||||
import { api } from "../api";
|
||||
import ProposalCard from "./ProposalCard.vue";
|
||||
|
||||
interface CardState {
|
||||
card: ProposalCardType;
|
||||
state: "pending" | "confirmed" | "rejected" | "executing" | "success" | "error";
|
||||
result?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const prompt = ref("");
|
||||
const planning = ref(false);
|
||||
const planError = ref("");
|
||||
const cards = ref<CardState[]>([]);
|
||||
|
||||
async function plan() {
|
||||
if (!prompt.value.trim()) return;
|
||||
planning.value = true;
|
||||
planError.value = "";
|
||||
try {
|
||||
const r = await api.post("/api/ai_admin/plan", { prompt: prompt.value });
|
||||
cards.value = (r.data.cards || []).map((c: ProposalCardType) => ({ card: c, state: "pending" }));
|
||||
if (!cards.value.length) planError.value = "Die KI konnte keinen Aktionsplan erzeugen.";
|
||||
} catch (e: any) {
|
||||
planError.value = e.response?.data?.detail || e.message;
|
||||
} finally {
|
||||
planning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateArgs(i: number, args: Record<string, any>) {
|
||||
cards.value[i].card.args = args;
|
||||
// remove from 'missing' those that now have values
|
||||
cards.value[i].card.missing = (cards.value[i].card.missing || []).filter(
|
||||
(k) => args[k] === undefined || args[k] === null || args[k] === ""
|
||||
);
|
||||
}
|
||||
|
||||
async function execute(i: number) {
|
||||
const cs = cards.value[i];
|
||||
if (cs.card.missing.length) {
|
||||
cs.state = "error";
|
||||
cs.error = `Fehlende Felder: ${cs.card.missing.join(", ")}`;
|
||||
return;
|
||||
}
|
||||
cs.state = "executing";
|
||||
try {
|
||||
const r = await api.post("/api/ai_admin/execute", {
|
||||
cards: [{ tool: cs.card.tool, args: cs.card.args }],
|
||||
});
|
||||
const res = r.data.results[0];
|
||||
if (res.ok) {
|
||||
cs.state = "success";
|
||||
cs.result = res.result;
|
||||
} else {
|
||||
cs.state = "error";
|
||||
cs.error = res.error;
|
||||
}
|
||||
} catch (e: any) {
|
||||
cs.state = "error";
|
||||
cs.error = e.response?.data?.detail || e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function reject(i: number) {
|
||||
cards.value[i].state = "rejected";
|
||||
}
|
||||
|
||||
async function confirmAll() {
|
||||
for (let i = 0; i < cards.value.length; i++) {
|
||||
if (cards.value[i].state === "pending") {
|
||||
await execute(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
cards.value = [];
|
||||
prompt.value = "";
|
||||
planError.value = "";
|
||||
}
|
||||
|
||||
const pendingCount = computed(() => cards.value.filter((c) => c.state === "pending").length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold mb-2">🤖 KI-Assistent</h2>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Sag, was getan werden soll. Die KI erzeugt nur Vorschläge — ausgeführt wird erst nach
|
||||
deiner Bestätigung. Du kannst auch JSON-Daten reinwerfen.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
v-model="prompt"
|
||||
rows="3"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="z.B. 'setze den Shopnamen auf TEST123' oder [{sku:'NEW-1',...}] erstelle diese"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button @click="plan" :disabled="planning" class="btn-primary">
|
||||
{{ planning ? "Plane..." : "Planen" }}
|
||||
</button>
|
||||
<button v-if="cards.length" @click="reset" class="btn-secondary">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="planning" class="text-xs text-gray-500 mt-2">
|
||||
Lokales LLM rechnet. Bei Bulk-Operationen über viele Items kann das ein paar Minuten dauern.
|
||||
</div>
|
||||
<div v-if="planError" class="text-red-600 text-sm mt-2">{{ planError }}</div>
|
||||
|
||||
<div v-if="cards.length" class="mt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="font-medium">{{ cards.length }} Vorschlag(e)</div>
|
||||
<button v-if="pendingCount > 1" @click="confirmAll" class="btn-success text-sm">
|
||||
Alle bestätigen ({{ pendingCount }})
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<ProposalCard
|
||||
v-for="(cs, i) in cards"
|
||||
:key="i"
|
||||
:card="cs.card"
|
||||
:state="cs.state"
|
||||
:result="cs.result"
|
||||
:error="cs.error"
|
||||
@update:args="(a) => updateArgs(i, a)"
|
||||
@confirm="execute(i)"
|
||||
@reject="reject(i)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
31
frontend/admin/src/components/HealthBadge.vue
Normal file
31
frontend/admin/src/components/HealthBadge.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api";
|
||||
|
||||
const ok = ref<boolean | null>(null);
|
||||
const info = ref<any>(null);
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const r = await api.get("/health");
|
||||
ok.value = r.data.ok;
|
||||
info.value = r.data;
|
||||
} catch {
|
||||
ok.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(check);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-600">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="ok === null ? 'bg-gray-300' : ok ? 'bg-green-500' : 'bg-red-500'"
|
||||
></span>
|
||||
<span>
|
||||
{{ ok === null ? "…" : ok ? `Backend online (${info?.apps?.length} Apps)` : "Backend offline" }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
137
frontend/admin/src/components/ProposalCard.vue
Normal file
137
frontend/admin/src/components/ProposalCard.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProposalCard } from "@shop/shared/types";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
card: ProposalCard;
|
||||
state: "pending" | "confirmed" | "rejected" | "executing" | "success" | "error";
|
||||
result?: any;
|
||||
error?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:args", args: Record<string, any>): void;
|
||||
(e: "confirm"): void;
|
||||
(e: "reject"): void;
|
||||
}>();
|
||||
|
||||
// local editable copy of args
|
||||
const localArgs = ref<Record<string, any>>({ ...props.card.args });
|
||||
|
||||
function update(key: string, value: any) {
|
||||
localArgs.value = { ...localArgs.value, [key]: value };
|
||||
emit("update:args", localArgs.value);
|
||||
}
|
||||
|
||||
const props_ = computed<Record<string, any>>(() => {
|
||||
return (props.card.schema?.properties as Record<string, any>) || {};
|
||||
});
|
||||
|
||||
const requiredKeys = computed<string[]>(
|
||||
() => (props.card.schema?.required as string[]) || []
|
||||
);
|
||||
|
||||
// union of schema keys + keys already present in args (so unusual fields show too)
|
||||
const allKeys = computed<string[]>(() => {
|
||||
const s = new Set<string>([...Object.keys(props_.value), ...Object.keys(localArgs.value || {})]);
|
||||
return Array.from(s);
|
||||
});
|
||||
|
||||
function isMissing(k: string): boolean {
|
||||
return props.card.missing?.includes(k) || false;
|
||||
}
|
||||
|
||||
function inputType(schema: any): string {
|
||||
const t = schema?.type;
|
||||
if (t === "number" || t === "integer") return "number";
|
||||
if (t === "boolean") return "checkbox";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function stringify(v: any) {
|
||||
if (v === null || v === undefined) return "";
|
||||
if (typeof v === "object") return JSON.stringify(v);
|
||||
return String(v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="card border-l-4"
|
||||
:class="{
|
||||
'border-l-yellow-400': state === 'pending',
|
||||
'border-l-blue-500': state === 'executing' || state === 'confirmed',
|
||||
'border-l-green-500': state === 'success',
|
||||
'border-l-red-500': state === 'error' || state === 'rejected',
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex-1">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500">{{ card.tool }}</div>
|
||||
<div class="font-medium">{{ card.preview }}</div>
|
||||
<div v-if="card.notes" class="text-xs text-gray-500 italic mt-1">{{ card.notes }}</div>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full"
|
||||
:class="{
|
||||
'bg-yellow-100 text-yellow-700': state === 'pending',
|
||||
'bg-blue-100 text-blue-700': state === 'executing' || state === 'confirmed',
|
||||
'bg-green-100 text-green-700': state === 'success',
|
||||
'bg-red-100 text-red-700': state === 'error' || state === 'rejected',
|
||||
}"
|
||||
>{{ state }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="allKeys.length" class="grid grid-cols-2 gap-2 mt-3">
|
||||
<div v-for="k in allKeys" :key="k">
|
||||
<label class="label">
|
||||
{{ k }}
|
||||
<span v-if="requiredKeys.includes(k)" class="text-red-500">*</span>
|
||||
<span
|
||||
v-if="isMissing(k)"
|
||||
class="ml-1 text-xs text-red-600 bg-red-50 px-1 rounded"
|
||||
>fehlt</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="inputType(props_[k]) === 'checkbox'"
|
||||
type="checkbox"
|
||||
:checked="!!localArgs[k]"
|
||||
:disabled="state !== 'pending'"
|
||||
@change="(e: any) => update(k, e.target.checked)"
|
||||
/>
|
||||
<textarea
|
||||
v-else-if="typeof localArgs[k] === 'object' && localArgs[k] !== null"
|
||||
class="input font-mono text-xs"
|
||||
rows="2"
|
||||
:disabled="state !== 'pending'"
|
||||
:value="stringify(localArgs[k])"
|
||||
@input="(e: any) => {
|
||||
try { update(k, JSON.parse(e.target.value)); } catch {}
|
||||
}"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:type="inputType(props_[k])"
|
||||
:step="props_[k]?.type === 'number' ? 'any' : undefined"
|
||||
:value="stringify(localArgs[k])"
|
||||
:disabled="state !== 'pending'"
|
||||
@input="(e: any) => update(k, props_[k]?.type === 'number' || props_[k]?.type === 'integer' ? Number(e.target.value) : e.target.value)"
|
||||
class="input"
|
||||
:class="{ 'border-red-400': isMissing(k) }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="state === 'success' && result" class="mt-3 text-sm text-green-700 bg-green-50 p-2 rounded">
|
||||
✓ Ausgeführt: {{ JSON.stringify(result) }}
|
||||
</div>
|
||||
<div v-if="state === 'error' && error" class="mt-3 text-sm text-red-700 bg-red-50 p-2 rounded">
|
||||
✗ {{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="state === 'pending'" class="mt-3 flex gap-2">
|
||||
<button @click="emit('confirm')" class="btn-success">Bestätigen</button>
|
||||
<button @click="emit('reject')" class="btn-secondary">Verwerfen</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
10
frontend/admin/src/main.ts
Normal file
10
frontend/admin/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
import { router } from "./router";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
102
frontend/admin/src/pages/Categories.vue
Normal file
102
frontend/admin/src/pages/Categories.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import type { Category } from "@shop/shared/types";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { api } from "../api";
|
||||
|
||||
const categories = ref<Category[]>([]);
|
||||
const form = reactive({
|
||||
slug: "",
|
||||
name_de: "",
|
||||
name_en: "",
|
||||
sort_order: 0,
|
||||
});
|
||||
const error = ref("");
|
||||
|
||||
async function load() {
|
||||
const r = await api.get("/api/catalog/admin/categories");
|
||||
categories.value = r.data;
|
||||
}
|
||||
|
||||
async function create() {
|
||||
error.value = "";
|
||||
try {
|
||||
await api.post("/api/catalog/admin/categories", {
|
||||
slug: form.slug,
|
||||
name: { de: form.name_de, en: form.name_en },
|
||||
sort_order: form.sort_order,
|
||||
});
|
||||
form.slug = "";
|
||||
form.name_de = "";
|
||||
form.name_en = "";
|
||||
form.sort_order = 0;
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function del(c: Category) {
|
||||
if (!confirm(`Kategorie ${c.slug} löschen?`)) return;
|
||||
await api.delete(`/api/catalog/admin/categories/${c.id}`);
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-4">Kategorien</h1>
|
||||
|
||||
<div class="grid md:grid-cols-[1fr_320px] gap-6">
|
||||
<div class="card">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Slug</th>
|
||||
<th>Name (DE)</th>
|
||||
<th>Sortierung</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in categories" :key="c.id">
|
||||
<td>{{ c.id }}</td>
|
||||
<td class="font-mono text-xs">{{ c.slug }}</td>
|
||||
<td>{{ c.name.de }}</td>
|
||||
<td>{{ c.sort_order }}</td>
|
||||
<td class="text-right">
|
||||
<button @click="del(c)" class="text-red-500 text-xs">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="font-semibold mb-3">Neue Kategorie</h2>
|
||||
<form @submit.prevent="create" class="space-y-2">
|
||||
<div>
|
||||
<label class="label">Slug</label>
|
||||
<input v-model="form.slug" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Name (DE)</label>
|
||||
<input v-model="form.name_de" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Name (EN)</label>
|
||||
<input v-model="form.name_en" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Sortierung</label>
|
||||
<input v-model.number="form.sort_order" type="number" class="input" />
|
||||
</div>
|
||||
<button class="btn-primary w-full">Erstellen</button>
|
||||
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
68
frontend/admin/src/pages/Dashboard.vue
Normal file
68
frontend/admin/src/pages/Dashboard.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api";
|
||||
import AIChatBox from "../components/AIChatBox.vue";
|
||||
|
||||
const stats = ref({ products: 0, categories: 0, orders: 0 });
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [p, c, o] = await Promise.all([
|
||||
api.get("/api/catalog/admin/products"),
|
||||
api.get("/api/catalog/admin/categories"),
|
||||
api.get("/api/orders/admin"),
|
||||
]);
|
||||
stats.value = {
|
||||
products: p.data.length,
|
||||
categories: c.data.length,
|
||||
orders: o.data.length,
|
||||
};
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold">Dashboard</h1>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="card">
|
||||
<div class="text-sm text-gray-500">Produkte</div>
|
||||
<div class="text-3xl font-bold">{{ stats.products }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="text-sm text-gray-500">Kategorien</div>
|
||||
<div class="text-3xl font-bold">{{ stats.categories }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="text-sm text-gray-500">Bestellungen</div>
|
||||
<div class="text-3xl font-bold">{{ stats.orders }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AIChatBox />
|
||||
|
||||
<div class="card">
|
||||
<h2 class="font-semibold mb-2">Beispiele für die KI-Box</h2>
|
||||
<ul class="list-disc pl-5 space-y-1 text-sm text-gray-700">
|
||||
<li>
|
||||
<code class="bg-gray-100 px-1">setze den Shopnamen auf TEST123</code>
|
||||
</li>
|
||||
<li>
|
||||
<code class="bg-gray-100 px-1">ändere den Preis von Produkt 1 auf 44.90</code>
|
||||
</li>
|
||||
<li>
|
||||
JSON-Bulk:
|
||||
<pre class="bg-gray-50 p-2 rounded mt-1 text-xs overflow-x-auto">Das sind neue Produkte, erstelle sie:
|
||||
[
|
||||
{"sku":"NEW-TS-RED","name_de":"Rotes T-Shirt","price":18.90,"stock":20},
|
||||
{"sku":"NEW-SOCK","name_de":"Socken","price":4.90,"stock":100},
|
||||
{"sku":"NEW-CAP","name_de":"Grüne Kappe","price":12.00,"stock":30}
|
||||
]</pre>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
35
frontend/admin/src/pages/Login.vue
Normal file
35
frontend/admin/src/pages/Login.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAuth } from "../stores/auth";
|
||||
|
||||
const auth = useAuth();
|
||||
const router = useRouter();
|
||||
const email = ref("admin@example.com");
|
||||
const password = ref("admin123");
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
await auth.login(email.value, password.value);
|
||||
router.push("/");
|
||||
} catch {
|
||||
// message surfaced via auth.error
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card w-96">
|
||||
<h1 class="text-2xl font-bold mb-1">Admin-Login</h1>
|
||||
<p class="text-sm text-gray-500 mb-4">Nur für Administratoren.</p>
|
||||
<form @submit.prevent="submit" class="space-y-3">
|
||||
<input v-model="email" type="email" placeholder="E-Mail" class="input" />
|
||||
<input v-model="password" type="password" placeholder="Passwort" class="input" />
|
||||
<button class="btn-primary w-full" :disabled="auth.loading">
|
||||
{{ auth.loading ? "..." : "Anmelden" }}
|
||||
</button>
|
||||
<div v-if="auth.error" class="text-red-600 text-sm">{{ auth.error }}</div>
|
||||
</form>
|
||||
<p class="text-xs text-gray-400 mt-3">Demo: admin@example.com / admin123</p>
|
||||
</div>
|
||||
</template>
|
||||
91
frontend/admin/src/pages/OrderDetail.vue
Normal file
91
frontend/admin/src/pages/OrderDetail.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import type { Order } from "@shop/shared/types";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api";
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
const order = ref<Order | null>(null);
|
||||
const newStatus = ref("");
|
||||
const note = ref("");
|
||||
const msg = ref("");
|
||||
|
||||
async function load() {
|
||||
const r = await api.get(`/api/orders/admin/${props.id}`);
|
||||
order.value = r.data;
|
||||
newStatus.value = r.data.status;
|
||||
}
|
||||
|
||||
async function updateStatus() {
|
||||
const r = await api.put(`/api/orders/admin/${props.id}/status`, {
|
||||
status: newStatus.value,
|
||||
note: note.value,
|
||||
});
|
||||
order.value = r.data;
|
||||
msg.value = "✓ Status aktualisiert";
|
||||
note.value = "";
|
||||
setTimeout(() => (msg.value = ""), 2000);
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="order" class="max-w-3xl">
|
||||
<h1 class="text-2xl font-bold mb-2">Bestellung #{{ order.id }}</h1>
|
||||
<div class="text-sm text-gray-500 mb-4">
|
||||
{{ new Date(order.created_at).toLocaleString() }}
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="card">
|
||||
<h2 class="font-semibold mb-2">Artikel</h2>
|
||||
<div v-for="it in order.items" :key="it.product_id" class="flex justify-between py-1">
|
||||
<span>{{ i18n.pickI18n(it.name) }} × {{ it.qty }}</span>
|
||||
<span>{{ it.line_total.toFixed(2) }} €</span>
|
||||
</div>
|
||||
<hr class="my-2" />
|
||||
<div class="flex justify-between font-bold">
|
||||
<span>Gesamt</span>
|
||||
<span>{{ order.total.toFixed(2) }} {{ order.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="font-semibold mb-2">Adresse</h2>
|
||||
<div class="text-sm">
|
||||
{{ order.address.name }}<br />
|
||||
{{ order.address.street }}<br />
|
||||
{{ order.address.zip }} {{ order.address.city }}<br />
|
||||
{{ order.address.country }}
|
||||
</div>
|
||||
<h2 class="font-semibold mt-4 mb-2">Zahlung</h2>
|
||||
<div class="text-sm text-gray-600">
|
||||
{{ order.payment.method || "dummy" }} — Ref {{ order.payment.transaction_id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card md:col-span-2">
|
||||
<h2 class="font-semibold mb-2">Status ändern</h2>
|
||||
<div class="flex gap-2 items-end">
|
||||
<div class="flex-1">
|
||||
<label class="label">Neuer Status</label>
|
||||
<select v-model="newStatus" class="input">
|
||||
<option value="paid">paid</option>
|
||||
<option value="packed">packed</option>
|
||||
<option value="shipped">shipped</option>
|
||||
<option value="delivered">delivered</option>
|
||||
<option value="cancelled">cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="label">Notiz</label>
|
||||
<input v-model="note" class="input" />
|
||||
</div>
|
||||
<button @click="updateStatus" class="btn-primary">Übernehmen</button>
|
||||
</div>
|
||||
<div v-if="msg" class="text-green-600 text-sm mt-2">{{ msg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
52
frontend/admin/src/pages/Orders.vue
Normal file
52
frontend/admin/src/pages/Orders.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import type { Order } from "@shop/shared/types";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import { api } from "../api";
|
||||
|
||||
const orders = ref<Order[]>([]);
|
||||
|
||||
async function load() {
|
||||
const r = await api.get("/api/orders/admin");
|
||||
orders.value = r.data;
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-4">Bestellungen ({{ orders.length }})</h1>
|
||||
<div class="card">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Datum</th>
|
||||
<th>Kunde</th>
|
||||
<th>Status</th>
|
||||
<th>Gesamt</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in orders" :key="o.id">
|
||||
<td>#{{ o.id }}</td>
|
||||
<td>{{ new Date(o.created_at).toLocaleString() }}</td>
|
||||
<td>{{ o.address?.name || "—" }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs"
|
||||
:class="o.status === 'paid' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'"
|
||||
>{{ o.status }}</span>
|
||||
</td>
|
||||
<td class="font-semibold">{{ o.total.toFixed(2) }} {{ o.currency }}</td>
|
||||
<td class="text-right">
|
||||
<RouterLink :to="`/orders/${o.id}`" class="text-admin-500 text-xs">Detail</RouterLink>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
156
frontend/admin/src/pages/ProductEdit.vue
Normal file
156
frontend/admin/src/pages/ProductEdit.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import type { Category, Product } from "@shop/shared/types";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
|
||||
const props = defineProps<{ id?: string }>();
|
||||
const router = useRouter();
|
||||
const form = ref<Partial<Product>>({
|
||||
sku: "",
|
||||
name: { de: "", en: "" },
|
||||
description: { de: "", en: "" },
|
||||
price: 0,
|
||||
currency: "EUR",
|
||||
stock: 0,
|
||||
active: true,
|
||||
image_url: "",
|
||||
category_id: null,
|
||||
attributes: {},
|
||||
});
|
||||
const categories = ref<Category[]>([]);
|
||||
const msg = ref("");
|
||||
const error = ref("");
|
||||
|
||||
async function loadCategories() {
|
||||
const r = await api.get("/api/catalog/admin/categories");
|
||||
categories.value = r.data;
|
||||
}
|
||||
|
||||
async function loadProduct() {
|
||||
if (!props.id) return;
|
||||
const r = await api.get(`/api/catalog/admin/products`);
|
||||
const p = r.data.find((x: Product) => String(x.id) === props.id);
|
||||
if (p) form.value = p;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
msg.value = "";
|
||||
error.value = "";
|
||||
try {
|
||||
const body = {
|
||||
sku: form.value.sku,
|
||||
name: form.value.name,
|
||||
description: form.value.description,
|
||||
price: Number(form.value.price),
|
||||
currency: form.value.currency,
|
||||
stock: Number(form.value.stock),
|
||||
active: form.value.active,
|
||||
image_url: form.value.image_url,
|
||||
category_id: form.value.category_id,
|
||||
attributes: form.value.attributes,
|
||||
};
|
||||
if (props.id) {
|
||||
await api.put(`/api/catalog/admin/products/${props.id}`, body);
|
||||
} else {
|
||||
await api.post(`/api/catalog/admin/products`, body);
|
||||
}
|
||||
msg.value = "Gespeichert";
|
||||
setTimeout(() => router.push("/products"), 500);
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const r = await api.post("/api/catalog/admin/upload", fd);
|
||||
form.value.image_url = r.data.url;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories();
|
||||
await loadProduct();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-3xl">
|
||||
<h1 class="text-2xl font-bold mb-4">{{ props.id ? "Produkt bearbeiten" : "Neues Produkt" }}</h1>
|
||||
<form @submit.prevent="save" class="card space-y-4">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">SKU</label>
|
||||
<input v-model="form.sku" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Kategorie</label>
|
||||
<select v-model="form.category_id" class="input">
|
||||
<option :value="null">—</option>
|
||||
<option v-for="c in categories" :key="c.id" :value="c.id">
|
||||
{{ c.name.de }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">Name (DE)</label>
|
||||
<input v-model="form.name!.de" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Name (EN)</label>
|
||||
<input v-model="form.name!.en" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">Beschreibung (DE)</label>
|
||||
<textarea v-model="form.description!.de" class="input" rows="3" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Beschreibung (EN)</label>
|
||||
<textarea v-model="form.description!.en" class="input" rows="3" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="label">Preis</label>
|
||||
<input v-model.number="form.price" type="number" step="0.01" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Währung</label>
|
||||
<input v-model="form.currency" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Bestand</label>
|
||||
<input v-model.number="form.stock" type="number" class="input" />
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="form.active" type="checkbox" />
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Bild</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="form.image_url" class="w-20 h-20 bg-gray-100 rounded overflow-hidden">
|
||||
<img :src="form.image_url" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<input type="file" accept="image/*" @change="upload" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn-primary">Speichern</button>
|
||||
<button type="button" @click="router.push('/products')" class="btn-secondary">Abbrechen</button>
|
||||
</div>
|
||||
<div v-if="msg" class="text-green-600 text-sm">{{ msg }}</div>
|
||||
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
67
frontend/admin/src/pages/Products.vue
Normal file
67
frontend/admin/src/pages/Products.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { i18n } from "@shop/shared";
|
||||
import type { Product } from "@shop/shared/types";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import { api } from "../api";
|
||||
|
||||
const products = ref<Product[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const r = await api.get("/api/catalog/admin/products");
|
||||
products.value = r.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function del(p: Product) {
|
||||
if (!confirm(`Produkt ${p.sku} wirklich löschen?`)) return;
|
||||
await api.delete(`/api/catalog/admin/products/${p.id}`);
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">Produkte ({{ products.length }})</h1>
|
||||
<RouterLink to="/products/new" class="btn-primary">+ Neues Produkt</RouterLink>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div v-if="loading" class="text-gray-500">Lädt...</div>
|
||||
<table v-else class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>SKU</th>
|
||||
<th>Name (DE)</th>
|
||||
<th>Preis</th>
|
||||
<th>Lager</th>
|
||||
<th>Aktiv</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in products" :key="p.id">
|
||||
<td>{{ p.id }}</td>
|
||||
<td class="font-mono text-xs">{{ p.sku }}</td>
|
||||
<td>{{ i18n.pickI18n(p.name, "de") }}</td>
|
||||
<td>{{ p.price.toFixed(2) }} €</td>
|
||||
<td>{{ p.stock }}</td>
|
||||
<td>{{ p.active ? "✓" : "–" }}</td>
|
||||
<td class="text-right">
|
||||
<RouterLink :to="`/products/${p.id}`" class="text-admin-500 text-xs mr-2">Bearbeiten</RouterLink>
|
||||
<button @click="del(p)" class="text-red-500 text-xs">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
69
frontend/admin/src/pages/Settings.vue
Normal file
69
frontend/admin/src/pages/Settings.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api";
|
||||
|
||||
const shopName = ref("");
|
||||
const msg = ref("");
|
||||
const reindexing = ref(false);
|
||||
const reindexResult = ref<any>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await api.get("/api/core/settings/core.shop_name");
|
||||
shopName.value = r.data.value || "";
|
||||
} catch {
|
||||
shopName.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
msg.value = "";
|
||||
await api.put("/api/core/settings/core.shop_name", { value: shopName.value });
|
||||
msg.value = "✓ Gespeichert";
|
||||
setTimeout(() => (msg.value = ""), 2000);
|
||||
}
|
||||
|
||||
async function reindex() {
|
||||
reindexing.value = true;
|
||||
reindexResult.value = null;
|
||||
try {
|
||||
const r = await api.post("/api/ai_core/reindex");
|
||||
reindexResult.value = r.data;
|
||||
} finally {
|
||||
reindexing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-2xl font-bold mb-4">Einstellungen</h1>
|
||||
|
||||
<div class="card space-y-3">
|
||||
<div>
|
||||
<label class="label">Shop-Name (core.shop_name)</label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="shopName" class="input" />
|
||||
<button @click="save" class="btn-primary">Speichern</button>
|
||||
</div>
|
||||
<div v-if="msg" class="text-green-600 text-sm mt-1">{{ msg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4 space-y-3">
|
||||
<h2 class="font-semibold">KI-Index</h2>
|
||||
<p class="text-sm text-gray-600">
|
||||
Baut Embeddings für alle Produkte und Kategorien neu auf (nötig nach Seed oder bei
|
||||
Inkonsistenzen).
|
||||
</p>
|
||||
<button @click="reindex" :disabled="reindexing" class="btn-secondary">
|
||||
{{ reindexing ? "Indexiere..." : "Neu indizieren" }}
|
||||
</button>
|
||||
<div v-if="reindexResult" class="text-sm text-green-700">
|
||||
✓ {{ reindexResult.products }} Produkte, {{ reindexResult.categories }} Kategorien
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
24
frontend/admin/src/router.ts
Normal file
24
frontend/admin/src/router.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import Categories from "./pages/Categories.vue";
|
||||
import Dashboard from "./pages/Dashboard.vue";
|
||||
import Login from "./pages/Login.vue";
|
||||
import OrderDetail from "./pages/OrderDetail.vue";
|
||||
import Orders from "./pages/Orders.vue";
|
||||
import ProductEdit from "./pages/ProductEdit.vue";
|
||||
import Products from "./pages/Products.vue";
|
||||
import Settings from "./pages/Settings.vue";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: "/login", component: Login },
|
||||
{ path: "/", component: Dashboard },
|
||||
{ path: "/products", component: Products },
|
||||
{ path: "/products/new", component: ProductEdit },
|
||||
{ path: "/products/:id", component: ProductEdit, props: true },
|
||||
{ path: "/categories", component: Categories },
|
||||
{ path: "/orders", component: Orders },
|
||||
{ path: "/orders/:id", component: OrderDetail, props: true },
|
||||
{ path: "/settings", component: Settings },
|
||||
],
|
||||
});
|
||||
47
frontend/admin/src/stores/auth.ts
Normal file
47
frontend/admin/src/stores/auth.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { clearTokens, saveTokens } from "@shop/shared/api";
|
||||
import type { User } from "@shop/shared/types";
|
||||
import { defineStore } from "pinia";
|
||||
import { api } from "../api";
|
||||
|
||||
export const useAuth = defineStore("auth", {
|
||||
state: () => ({
|
||||
user: null as User | null,
|
||||
loading: false,
|
||||
error: "",
|
||||
}),
|
||||
getters: {
|
||||
isAdmin: (s) => s.user?.role === "admin",
|
||||
},
|
||||
actions: {
|
||||
async fetchMe() {
|
||||
try {
|
||||
const r = await api.get("/api/auth/me");
|
||||
this.user = r.data;
|
||||
} catch {
|
||||
this.user = null;
|
||||
}
|
||||
},
|
||||
async login(email: string, password: string) {
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
try {
|
||||
const r = await api.post("/api/auth/login", { email, password });
|
||||
if (r.data.role !== "admin") {
|
||||
clearTokens();
|
||||
throw new Error("Nur Admins dürfen sich hier anmelden.");
|
||||
}
|
||||
saveTokens(r.data.access_token, r.data.refresh_token);
|
||||
await this.fetchMe();
|
||||
} catch (e: any) {
|
||||
this.error = e.response?.data?.detail || e.message;
|
||||
throw e;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
logout() {
|
||||
clearTokens();
|
||||
this.user = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
42
frontend/admin/src/style.css
Normal file
42
frontend/admin/src/style.css
Normal file
@@ -0,0 +1,42 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-gray-100 text-gray-900;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-md px-4 py-2 font-medium transition;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-admin-500 text-white hover:bg-admin-600 disabled:opacity-50;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply btn border border-gray-300 bg-white text-gray-700 hover:bg-gray-50;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply btn bg-red-500 text-white hover:bg-red-600;
|
||||
}
|
||||
.btn-success {
|
||||
@apply btn bg-green-500 text-white hover:bg-green-600;
|
||||
}
|
||||
.input {
|
||||
@apply w-full rounded-md border border-gray-300 px-3 py-2 focus:border-admin-500 focus:outline-none focus:ring-1 focus:ring-admin-500;
|
||||
}
|
||||
.card {
|
||||
@apply rounded-lg bg-white p-4 shadow-sm border border-gray-200;
|
||||
}
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
table.data-table {
|
||||
@apply w-full text-sm;
|
||||
}
|
||||
table.data-table th {
|
||||
@apply text-left font-medium text-gray-600 border-b border-gray-200 px-2 py-2;
|
||||
}
|
||||
table.data-table td {
|
||||
@apply border-b border-gray-100 px-2 py-2;
|
||||
}
|
||||
16
frontend/admin/tailwind.config.js
Normal file
16
frontend/admin/tailwind.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{vue,ts,js}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
admin: {
|
||||
500: "#1565c0",
|
||||
600: "#134a92",
|
||||
700: "#0d3778",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
22
frontend/admin/tsconfig.json
Normal file
22
frontend/admin/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@shop/shared": ["../shared/src/index.ts"],
|
||||
"@shop/shared/*": ["../shared/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "src/**/*.vue"]
|
||||
}
|
||||
15
frontend/admin/vite.config.ts
Normal file
15
frontend/admin/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8000",
|
||||
"/uploads": "http://localhost:8000",
|
||||
"/health": "http://localhost:8000",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user