update grpc
This commit is contained in:
334
src/routes/admin/AdTemplates.vue
Normal file
334
src/routes/admin/AdTemplates.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminAdTemplateRow = any;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminAdTemplateRow[]>([]);
|
||||
const selectedRow = ref<AdminAdTemplateRow | null>(null);
|
||||
const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const formatOptions = ["pre-roll", "mid-roll", "post-roll"];
|
||||
|
||||
const createForm = reactive({
|
||||
userId: "",
|
||||
name: "",
|
||||
description: "",
|
||||
vastTagUrl: "",
|
||||
adFormat: "pre-roll",
|
||||
duration: null as number | null,
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const editForm = reactive({
|
||||
id: "",
|
||||
userId: "",
|
||||
name: "",
|
||||
description: "",
|
||||
vastTagUrl: "",
|
||||
adFormat: "pre-roll",
|
||||
duration: null as number | null,
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const canCreate = computed(() => createForm.userId.trim() && createForm.name.trim() && createForm.vastTagUrl.trim());
|
||||
const canUpdate = computed(() => editForm.id.trim() && editForm.userId.trim() && editForm.name.trim() && editForm.vastTagUrl.trim());
|
||||
|
||||
const loadTemplates = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminAdTemplates({ page: 1, limit: 20 });
|
||||
rows.value = response.templates ?? [];
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin ad templates";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.userId = "";
|
||||
createForm.name = "";
|
||||
createForm.description = "";
|
||||
createForm.vastTagUrl = "";
|
||||
createForm.adFormat = "pre-roll";
|
||||
createForm.duration = null;
|
||||
createForm.isActive = true;
|
||||
createForm.isDefault = false;
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
editOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminAdTemplateRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
editForm.id = row.id || "";
|
||||
editForm.userId = row.userId || "";
|
||||
editForm.name = row.name || "";
|
||||
editForm.description = row.description || "";
|
||||
editForm.vastTagUrl = row.vastTagUrl || "";
|
||||
editForm.adFormat = row.adFormat || "pre-roll";
|
||||
editForm.duration = row.duration ?? null;
|
||||
editForm.isActive = !!row.isActive;
|
||||
editForm.isDefault = !!row.isDefault;
|
||||
editOpen.value = true;
|
||||
};
|
||||
|
||||
const openDeleteDialog = (row: AdminAdTemplateRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
deleteOpen.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!canCreate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.createAdminAdTemplate({
|
||||
userId: createForm.userId.trim(),
|
||||
name: createForm.name.trim(),
|
||||
description: createForm.description.trim() || undefined,
|
||||
vastTagUrl: createForm.vastTagUrl.trim(),
|
||||
adFormat: createForm.adFormat,
|
||||
duration: createForm.duration == null ? undefined : createForm.duration,
|
||||
isActive: createForm.isActive,
|
||||
isDefault: createForm.isDefault,
|
||||
});
|
||||
resetCreateForm();
|
||||
createOpen.value = false;
|
||||
await loadTemplates();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to create ad template";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!canUpdate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.updateAdminAdTemplate({
|
||||
id: editForm.id,
|
||||
userId: editForm.userId.trim(),
|
||||
name: editForm.name.trim(),
|
||||
description: editForm.description.trim() || undefined,
|
||||
vastTagUrl: editForm.vastTagUrl.trim(),
|
||||
adFormat: editForm.adFormat,
|
||||
duration: editForm.duration == null ? undefined : editForm.duration,
|
||||
isActive: editForm.isActive,
|
||||
isDefault: editForm.isDefault,
|
||||
});
|
||||
editOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadTemplates();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update ad template";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitDelete = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.deleteAdminAdTemplate({ id: selectedRow.value.id });
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadTemplates();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to delete ad template";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadTemplates);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Ad Templates"
|
||||
description="Cross-user ad template management over admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex justify-end">
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create template</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">Name</th>
|
||||
<th class="py-3 pr-4 font-medium">Owner</th>
|
||||
<th class="py-3 pr-4 font-medium">Format</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Default</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="6" class="py-6 text-center text-gray-500">Loading ad templates...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="6" class="py-6 text-center text-gray-500">No ad templates found.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">
|
||||
<div class="font-medium">{{ row.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ row.vastTagUrl }}</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.ownerEmail || row.userId }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.adFormat || 'pre-roll' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.isDefault ? 'YES' : 'NO' }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create ad template" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="createForm.userId" placeholder="user-id" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Name</label>
|
||||
<AppInput v-model="createForm.name" placeholder="Preroll template" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="createForm.description" rows="3" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">VAST URL</label>
|
||||
<AppInput v-model="createForm.vastTagUrl" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Ad format</label>
|
||||
<select v-model="createForm.adFormat" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="format in formatOptions" :key="format" :value="format">{{ format }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Duration</label>
|
||||
<AppInput v-model="createForm.duration" type="number" min="0" placeholder="Optional" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input v-model="createForm.isActive" type="checkbox" class="h-4 w-4" />
|
||||
Active
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input v-model="createForm.isDefault" type="checkbox" class="h-4 w-4" />
|
||||
Default
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="editOpen" title="Edit ad template" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="editForm.userId" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Name</label>
|
||||
<AppInput v-model="editForm.name" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="editForm.description" rows="3" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">VAST URL</label>
|
||||
<AppInput v-model="editForm.vastTagUrl" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Ad format</label>
|
||||
<select v-model="editForm.adFormat" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="format in formatOptions" :key="format" :value="format">{{ format }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Duration</label>
|
||||
<AppInput v-model="editForm.duration" type="number" min="0" placeholder="Optional" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input v-model="editForm.isActive" type="checkbox" class="h-4 w-4" />
|
||||
Active
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input v-model="editForm.isDefault" type="checkbox" class="h-4 w-4" />
|
||||
Default
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canUpdate" @click="submitEdit">Save</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="deleteOpen" title="Delete ad template" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Delete ad template <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitDelete">Delete</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
191
src/routes/admin/Agents.vue
Normal file
191
src/routes/admin/Agents.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import { onMounted, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminAgentRow = any;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminAgentRow[]>([]);
|
||||
const selectedRow = ref<AdminAgentRow | null>(null);
|
||||
const restartOpen = ref(false);
|
||||
const updateOpen = ref(false);
|
||||
|
||||
const loadAgents = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminAgents();
|
||||
rows.value = response.agents ?? [];
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin agents";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
restartOpen.value = false;
|
||||
updateOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const openRestartDialog = (row: AdminAgentRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
restartOpen.value = true;
|
||||
};
|
||||
|
||||
const openUpdateDialog = (row: AdminAgentRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
updateOpen.value = true;
|
||||
};
|
||||
|
||||
const submitRestart = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.restartAdminAgent({ id: selectedRow.value.id });
|
||||
restartOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadAgents();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to restart agent";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitUpdate = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.updateAdminAgent({ id: selectedRow.value.id });
|
||||
updateOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadAgents();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update agent";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
if (topic !== "picpic/events" || payload?.type !== "agent_update") return;
|
||||
const update = payload.payload;
|
||||
if (!update?.id) return;
|
||||
const row = rows.value.find((item) => item.id === update.id);
|
||||
if (row) {
|
||||
Object.assign(row, {
|
||||
...row,
|
||||
...update,
|
||||
lastHeartbeat: update.last_heartbeat || row.lastHeartbeat,
|
||||
createdAt: update.created_at || row.createdAt,
|
||||
updatedAt: update.updated_at || row.updatedAt,
|
||||
});
|
||||
} else {
|
||||
loadAgents();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(loadAgents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Agents"
|
||||
description="Connected render workers and command controls over admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex justify-end">
|
||||
<AppButton size="sm" variant="secondary" @click="loadAgents">Refresh agents</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">Agent</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Platform</th>
|
||||
<th class="py-3 pr-4 font-medium">Version</th>
|
||||
<th class="py-3 pr-4 font-medium">CPU</th>
|
||||
<th class="py-3 pr-4 font-medium">RAM</th>
|
||||
<th class="py-3 pr-4 font-medium">Heartbeat</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="8" class="py-6 text-center text-gray-500">Loading agents...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="8" class="py-6 text-center text-gray-500">No agents connected.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">
|
||||
<div class="font-medium">{{ row.name || row.id }}</div>
|
||||
<div class="text-xs text-gray-500">{{ row.id }}</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.platform || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.version || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.cpu ?? 0 }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.ram ?? 0 }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.lastHeartbeat ? new Date(row.lastHeartbeat).toLocaleString() : '—' }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openUpdateDialog(row)">Update</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openRestartDialog(row)">Restart</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="restartOpen" title="Restart agent" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Send restart command to <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Back</AppButton>
|
||||
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitRestart">Restart</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="updateOpen" title="Update agent" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Send update command to <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Back</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" @click="submitUpdate">Update</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
362
src/routes/admin/Jobs.vue
Normal file
362
src/routes/admin/Jobs.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminJobRow = any;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminJobRow[]>([]);
|
||||
const selectedRow = ref<AdminJobRow | null>(null);
|
||||
const selectedLogs = ref("");
|
||||
const createOpen = ref(false);
|
||||
const logsOpen = ref(false);
|
||||
const cancelOpen = ref(false);
|
||||
const retryOpen = ref(false);
|
||||
const activeAgentFilter = ref("");
|
||||
|
||||
const createForm = reactive({
|
||||
command: "",
|
||||
image: "alpine",
|
||||
userId: "",
|
||||
name: "",
|
||||
timeLimit: 0,
|
||||
priority: 0,
|
||||
envText: "",
|
||||
});
|
||||
|
||||
const parseEnvText = (value: string) =>
|
||||
value
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.reduce<Record<string, string>>((acc, line) => {
|
||||
const separatorIndex = line.indexOf("=");
|
||||
if (separatorIndex === -1) return acc;
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
const val = line.slice(separatorIndex + 1).trim();
|
||||
if (key) {
|
||||
acc[key] = val;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const hasEnv = computed(() => Object.keys(parseEnvText(createForm.envText)).length > 0);
|
||||
const canCreate = computed(() => createForm.command.trim().length > 0);
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.command = "";
|
||||
createForm.image = "alpine";
|
||||
createForm.userId = "";
|
||||
createForm.name = "";
|
||||
createForm.timeLimit = 0;
|
||||
createForm.priority = 0;
|
||||
createForm.envText = "";
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
logsOpen.value = false;
|
||||
cancelOpen.value = false;
|
||||
retryOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
selectedLogs.value = "";
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const loadJobs = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminJobs({
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
agentId: activeAgentFilter.value.trim() || undefined,
|
||||
});
|
||||
rows.value = response.jobs ?? [];
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin jobs";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openLogsDialog = async (row: AdminJobRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
selectedLogs.value = "Loading logs...";
|
||||
logsOpen.value = true;
|
||||
try {
|
||||
const response = await rpcClient.getAdminJobLogs({ id: row.id });
|
||||
selectedLogs.value = response.logs || "No logs available.";
|
||||
} catch (err: any) {
|
||||
selectedLogs.value = "";
|
||||
actionError.value = err?.message || "Failed to load job logs";
|
||||
}
|
||||
};
|
||||
|
||||
const openCancelDialog = (row: AdminJobRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
cancelOpen.value = true;
|
||||
};
|
||||
|
||||
const openRetryDialog = (row: AdminJobRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
retryOpen.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!canCreate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.createAdminJob({
|
||||
command: createForm.command.trim(),
|
||||
image: createForm.image.trim() || undefined,
|
||||
userId: createForm.userId.trim() || undefined,
|
||||
name: createForm.name.trim() || undefined,
|
||||
timeLimit: createForm.timeLimit > 0 ? createForm.timeLimit : undefined,
|
||||
priority: createForm.priority,
|
||||
env: hasEnv.value ? parseEnvText(createForm.envText) : undefined,
|
||||
});
|
||||
resetCreateForm();
|
||||
createOpen.value = false;
|
||||
await loadJobs();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to create job";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitCancel = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.cancelAdminJob({ id: selectedRow.value.id });
|
||||
cancelOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadJobs();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to cancel job";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitRetry = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.retryAdminJob({ id: selectedRow.value.id });
|
||||
retryOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadJobs();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to retry job";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
if (topic.startsWith("picpic/job/") && payload?.type === "job_update") {
|
||||
const update = payload.payload;
|
||||
const jobId = update?.job_id;
|
||||
const status = update?.status;
|
||||
if (!jobId || !status) return;
|
||||
const row = rows.value.find((item) => item.id === jobId);
|
||||
if (row) {
|
||||
row.status = status;
|
||||
row.updatedAt = new Date().toISOString();
|
||||
} else {
|
||||
loadJobs();
|
||||
}
|
||||
}
|
||||
|
||||
if (topic.startsWith("picpic/logs/") && payload?.job_id) {
|
||||
const row = rows.value.find((item) => item.id === payload.job_id);
|
||||
if (row && typeof payload.line === "string") {
|
||||
row.logs = `${row.logs || ""}${payload.line.endsWith("\n") ? payload.line : `${payload.line}\n`}`;
|
||||
row.progress = payload.progress ?? row.progress;
|
||||
}
|
||||
if (selectedRow.value?.id === payload.job_id && typeof payload.line === "string") {
|
||||
const nextLine = payload.line.endsWith("\n") ? payload.line : `${payload.line}\n`;
|
||||
selectedLogs.value = `${selectedLogs.value === "Loading logs..." ? "" : selectedLogs.value}${nextLine}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (topic === "picpic/events" && payload?.type === "resource_update") {
|
||||
const update = payload.payload;
|
||||
if (!update?.agent_id) return;
|
||||
rows.value.forEach((row) => {
|
||||
if (row.agentId === update.agent_id) {
|
||||
row.updatedAt = new Date().toISOString();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(loadJobs);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Jobs"
|
||||
description="Runtime job queue over admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div class="w-full max-w-sm space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Filter by agent ID</label>
|
||||
<div class="flex gap-2">
|
||||
<AppInput v-model="activeAgentFilter" placeholder="Optional agent ID" />
|
||||
<AppButton size="sm" variant="secondary" @click="loadJobs">Apply</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create job</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">Name</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Agent</th>
|
||||
<th class="py-3 pr-4 font-medium">Priority</th>
|
||||
<th class="py-3 pr-4 font-medium">Progress</th>
|
||||
<th class="py-3 pr-4 font-medium">Updated</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">Loading jobs...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">No jobs found.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">
|
||||
<div class="font-medium">{{ row.name || row.id }}</div>
|
||||
<div class="text-xs text-gray-500">{{ row.id }}</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.agentId || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.priority }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.progress || 0 }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.updatedAt ? new Date(row.updatedAt).toLocaleString() : '—' }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openLogsDialog(row)">Logs</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="openRetryDialog(row)">Retry</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openCancelDialog(row)">Cancel</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create job" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Command</label>
|
||||
<AppInput v-model="createForm.command" placeholder="ffmpeg -i ..." />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Image</label>
|
||||
<AppInput v-model="createForm.image" placeholder="alpine" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="createForm.userId" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Display name</label>
|
||||
<AppInput v-model="createForm.name" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Priority</label>
|
||||
<AppInput v-model="createForm.priority" type="number" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Time limit</label>
|
||||
<AppInput v-model="createForm.timeLimit" type="number" min="0" placeholder="Seconds" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Environment</label>
|
||||
<textarea v-model="createForm.envText" rows="5" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="KEY=value per line" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="logsOpen" title="Job logs" maxWidthClass="max-w-3xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-950 p-4 font-mono text-xs text-green-300 whitespace-pre-wrap max-h-120 overflow-auto">
|
||||
{{ selectedLogs || 'No logs available.' }}
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" @click="closeDialogs">Close</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="cancelOpen" title="Cancel job" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Cancel job <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Back</AppButton>
|
||||
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitCancel">Cancel job</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="retryOpen" title="Retry job" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Retry job <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Back</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" @click="submitRetry">Retry</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
3
src/routes/admin/Layout.vue
Normal file
3
src/routes/admin/Layout.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
59
src/routes/admin/Logs.vue
Normal file
59
src/routes/admin/Logs.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const jobId = ref("");
|
||||
const logs = ref("Enter a job ID and load logs.");
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!jobId.value.trim()) return;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.getAdminJobLogs({ id: jobId.value.trim() });
|
||||
logs.value = response.logs || "No logs available.";
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load logs";
|
||||
logs.value = "";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
if (!jobId.value.trim()) return;
|
||||
if (topic === `picpic/logs/${jobId.value.trim()}` && payload?.job_id === jobId.value.trim() && typeof payload.line === "string") {
|
||||
const nextLine = payload.line.endsWith("\n") ? payload.line : `${payload.line}\n`;
|
||||
logs.value = `${logs.value === "Enter a job ID and load logs." ? "" : logs.value}${nextLine}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Logs"
|
||||
description="Fetch persisted logs by job ID over admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<div class="w-full max-w-xl space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Job ID</label>
|
||||
<AppInput v-model="jobId" placeholder="job-..." />
|
||||
</div>
|
||||
<AppButton size="sm" :loading="loading" @click="loadLogs">Load logs</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-gray-950 p-4 font-mono text-sm text-green-300 whitespace-pre-wrap min-h-80 overflow-auto">
|
||||
{{ loading ? 'Loading logs...' : logs }}
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
</template>
|
||||
55
src/routes/admin/Overview.vue
Normal file
55
src/routes/admin/Overview.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import StatsCard from "@/components/dashboard/StatsCard.vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const dashboard = ref<any | null>(null);
|
||||
|
||||
const cards = computed(() => {
|
||||
const data = dashboard.value;
|
||||
return [
|
||||
{ title: "Total users", value: data?.totalUsers ?? 0, color: "primary" as const },
|
||||
{ title: "Total videos", value: data?.totalVideos ?? 0, color: "info" as const },
|
||||
{ title: "Payments", value: data?.totalPayments ?? 0, color: "success" as const },
|
||||
{ title: "Revenue", value: data?.totalRevenue ?? 0, color: "warning" as const },
|
||||
{ title: "Active subscriptions", value: data?.activeSubscriptions ?? 0, color: "primary" as const },
|
||||
{ title: "Ad templates", value: data?.totalAdTemplates ?? 0, color: "info" as const },
|
||||
{ title: "New users today", value: data?.newUsersToday ?? 0, color: "success" as const },
|
||||
{ title: "New videos today", value: data?.newVideosToday ?? 0, color: "warning" as const },
|
||||
];
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
dashboard.value = await rpcClient.getAdminDashboard();
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin dashboard";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Overview"
|
||||
description="System-wide metrics from backend gRPC admin service."
|
||||
>
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatsCard
|
||||
v-for="card in cards"
|
||||
:key="card.title"
|
||||
:title="card.title"
|
||||
:value="loading ? 0 : card.value"
|
||||
:color="card.color"
|
||||
/>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
</template>
|
||||
223
src/routes/admin/Payments.vue
Normal file
223
src/routes/admin/Payments.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminPaymentRow = any;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminPaymentRow[]>([]);
|
||||
const selectedRow = ref<AdminPaymentRow | null>(null);
|
||||
const createOpen = ref(false);
|
||||
const statusOpen = ref(false);
|
||||
|
||||
const paymentMethodOptions = ["TOPUP", "WALLET"];
|
||||
const statusOptions = ["PENDING", "SUCCESS", "FAILED", "CANCELLED"];
|
||||
|
||||
const createForm = reactive({
|
||||
userId: "",
|
||||
planId: "",
|
||||
termMonths: 1,
|
||||
paymentMethod: "TOPUP",
|
||||
topupAmount: null as number | null,
|
||||
});
|
||||
|
||||
const statusForm = reactive({
|
||||
id: "",
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
const canCreate = computed(() => createForm.userId.trim() && createForm.planId.trim() && createForm.termMonths >= 1 && createForm.paymentMethod.trim());
|
||||
const canUpdateStatus = computed(() => statusForm.id.trim() && statusForm.status.trim());
|
||||
|
||||
const loadPayments = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminPayments({ page: 1, limit: 20 });
|
||||
rows.value = response.payments ?? [];
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin payments";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.userId = "";
|
||||
createForm.planId = "";
|
||||
createForm.termMonths = 1;
|
||||
createForm.paymentMethod = "TOPUP";
|
||||
createForm.topupAmount = null;
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
statusOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const openStatusDialog = (row: AdminPaymentRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
statusForm.id = row.id || "";
|
||||
statusForm.status = row.status || "PENDING";
|
||||
statusOpen.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!canCreate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.createAdminPayment({
|
||||
userId: createForm.userId.trim(),
|
||||
planId: createForm.planId.trim(),
|
||||
termMonths: createForm.termMonths,
|
||||
paymentMethod: createForm.paymentMethod,
|
||||
topupAmount: createForm.topupAmount == null ? undefined : createForm.topupAmount,
|
||||
});
|
||||
resetCreateForm();
|
||||
createOpen.value = false;
|
||||
await loadPayments();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to create payment";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitStatusUpdate = async () => {
|
||||
if (!canUpdateStatus.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.updateAdminPayment({
|
||||
id: statusForm.id,
|
||||
status: statusForm.status,
|
||||
});
|
||||
statusOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadPayments();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update payment";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadPayments);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Payments"
|
||||
description="Payment history from admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex justify-end">
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create payment</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">ID</th>
|
||||
<th class="py-3 pr-4 font-medium">User</th>
|
||||
<th class="py-3 pr-4 font-medium">Amount</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Plan</th>
|
||||
<th class="py-3 pr-4 font-medium">Method</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">Loading payments...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">No payments found.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.id }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.userEmail || row.userId }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.amount }} {{ row.currency }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.planName || row.planId || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.paymentMethod || '—' }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openStatusDialog(row)">Update status</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin payment" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">User ID</label>
|
||||
<AppInput v-model="createForm.userId" placeholder="user-id" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Plan ID</label>
|
||||
<AppInput v-model="createForm.planId" placeholder="plan-id" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Term months</label>
|
||||
<AppInput v-model="createForm.termMonths" type="number" min="1" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Payment method</label>
|
||||
<select v-model="createForm.paymentMethod" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="method in paymentMethodOptions" :key="method" :value="method">{{ method }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Topup amount</label>
|
||||
<AppInput v-model="createForm.topupAmount" type="number" min="0" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="statusOpen" title="Update payment status" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Status</label>
|
||||
<select v-model="statusForm.status" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canUpdateStatus" @click="submitStatusUpdate">Save</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
342
src/routes/admin/Plans.vue
Normal file
342
src/routes/admin/Plans.vue
Normal file
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminPlanRow = any;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminPlanRow[]>([]);
|
||||
const selectedRow = ref<AdminPlanRow | null>(null);
|
||||
const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const cycleOptions = ["monthly", "quarterly", "yearly"];
|
||||
|
||||
const createForm = reactive({
|
||||
name: "",
|
||||
description: "",
|
||||
featuresText: "",
|
||||
price: 0,
|
||||
cycle: "monthly",
|
||||
storageLimit: 1,
|
||||
uploadLimit: 1,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const editForm = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
featuresText: "",
|
||||
price: 0,
|
||||
cycle: "monthly",
|
||||
storageLimit: 1,
|
||||
uploadLimit: 1,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const parseFeatures = (value: string) =>
|
||||
value
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const canCreate = computed(() => createForm.name.trim() && createForm.cycle.trim() && createForm.storageLimit > 0 && createForm.uploadLimit > 0);
|
||||
const canUpdate = computed(() => editForm.id.trim() && editForm.name.trim() && editForm.cycle.trim() && editForm.storageLimit > 0 && editForm.uploadLimit > 0);
|
||||
|
||||
const loadPlans = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminPlans();
|
||||
rows.value = response.plans ?? [];
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin plans";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.name = "";
|
||||
createForm.description = "";
|
||||
createForm.featuresText = "";
|
||||
createForm.price = 0;
|
||||
createForm.cycle = "monthly";
|
||||
createForm.storageLimit = 1;
|
||||
createForm.uploadLimit = 1;
|
||||
createForm.isActive = true;
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
editOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminPlanRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
editForm.id = row.id || "";
|
||||
editForm.name = row.name || "";
|
||||
editForm.description = row.description || "";
|
||||
editForm.featuresText = (row.features ?? []).join("\n");
|
||||
editForm.price = row.price ?? 0;
|
||||
editForm.cycle = row.cycle || "monthly";
|
||||
editForm.storageLimit = row.storageLimit ?? 1;
|
||||
editForm.uploadLimit = row.uploadLimit ?? 1;
|
||||
editForm.isActive = !!row.isActive;
|
||||
editOpen.value = true;
|
||||
};
|
||||
|
||||
const openDeleteDialog = (row: AdminPlanRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
deleteOpen.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!canCreate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.createAdminPlan({
|
||||
name: createForm.name.trim(),
|
||||
description: createForm.description.trim() || undefined,
|
||||
features: parseFeatures(createForm.featuresText),
|
||||
price: createForm.price,
|
||||
cycle: createForm.cycle,
|
||||
storageLimit: createForm.storageLimit,
|
||||
uploadLimit: createForm.uploadLimit,
|
||||
isActive: createForm.isActive,
|
||||
});
|
||||
resetCreateForm();
|
||||
createOpen.value = false;
|
||||
await loadPlans();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to create plan";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!canUpdate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.updateAdminPlan({
|
||||
id: editForm.id,
|
||||
name: editForm.name.trim(),
|
||||
description: editForm.description.trim() || undefined,
|
||||
features: parseFeatures(editForm.featuresText),
|
||||
price: editForm.price,
|
||||
cycle: editForm.cycle,
|
||||
storageLimit: editForm.storageLimit,
|
||||
uploadLimit: editForm.uploadLimit,
|
||||
isActive: editForm.isActive,
|
||||
});
|
||||
editOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadPlans();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update plan";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitDelete = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.deleteAdminPlan({ id: selectedRow.value.id });
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadPlans();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to delete plan";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadPlans);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Plans"
|
||||
description="Subscription plans managed via admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex justify-end">
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create plan</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">Name</th>
|
||||
<th class="py-3 pr-4 font-medium">Price</th>
|
||||
<th class="py-3 pr-4 font-medium">Cycle</th>
|
||||
<th class="py-3 pr-4 font-medium">Storage</th>
|
||||
<th class="py-3 pr-4 font-medium">Uploads</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">Loading plans...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">No plans found.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">
|
||||
<div class="font-medium">{{ row.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ row.description || '—' }}</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.price }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.cycle }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.storageLimit }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.uploadLimit }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin plan" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Name</label>
|
||||
<AppInput v-model="createForm.name" placeholder="Starter" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="createForm.description" rows="3" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Features</label>
|
||||
<textarea v-model="createForm.featuresText" rows="4" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="One feature per line" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Price</label>
|
||||
<AppInput v-model="createForm.price" type="number" min="0" step="0.01" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Cycle</label>
|
||||
<select v-model="createForm.cycle" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="cycle in cycleOptions" :key="cycle" :value="cycle">{{ cycle }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Storage limit</label>
|
||||
<AppInput v-model="createForm.storageLimit" type="number" min="1" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Upload limit</label>
|
||||
<AppInput v-model="createForm.uploadLimit" type="number" min="1" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 md:col-span-2">
|
||||
<input v-model="createForm.isActive" type="checkbox" class="h-4 w-4" />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="editOpen" title="Edit plan" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Name</label>
|
||||
<AppInput v-model="editForm.name" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="editForm.description" rows="3" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Features</label>
|
||||
<textarea v-model="editForm.featuresText" rows="4" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Price</label>
|
||||
<AppInput v-model="editForm.price" type="number" min="0" step="0.01" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Cycle</label>
|
||||
<select v-model="editForm.cycle" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="cycle in cycleOptions" :key="cycle" :value="cycle">{{ cycle }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Storage limit</label>
|
||||
<AppInput v-model="editForm.storageLimit" type="number" min="1" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Upload limit</label>
|
||||
<AppInput v-model="editForm.uploadLimit" type="number" min="1" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 md:col-span-2">
|
||||
<input v-model="editForm.isActive" type="checkbox" class="h-4 w-4" />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canUpdate" @click="submitEdit">Save</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="deleteOpen" title="Delete plan" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Delete or deactivate plan <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitDelete">Delete</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
351
src/routes/admin/Users.vue
Normal file
351
src/routes/admin/Users.vue
Normal file
@@ -0,0 +1,351 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminUserRow = any;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminUserRow[]>([]);
|
||||
const roleOptions = ["USER", "ADMIN"];
|
||||
|
||||
const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const roleOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const selectedRow = ref<AdminUserRow | null>(null);
|
||||
|
||||
const createForm = reactive({
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
role: "USER",
|
||||
planId: "",
|
||||
});
|
||||
|
||||
const editForm = reactive({
|
||||
id: "",
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
role: "USER",
|
||||
planId: "",
|
||||
});
|
||||
|
||||
const roleForm = reactive({
|
||||
id: "",
|
||||
role: "USER",
|
||||
});
|
||||
|
||||
const canCreate = computed(() => createForm.email.trim() && createForm.password.trim() && createForm.role.trim());
|
||||
const canUpdate = computed(() => editForm.id.trim() && editForm.email.trim() && editForm.role.trim());
|
||||
const canUpdateRole = computed(() => roleForm.id.trim() && roleForm.role.trim());
|
||||
|
||||
const normalizeOptional = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.email = "";
|
||||
createForm.username = "";
|
||||
createForm.password = "";
|
||||
createForm.role = "USER";
|
||||
createForm.planId = "";
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
editOpen.value = false;
|
||||
roleOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminUsers({ page: 1, limit: 20 });
|
||||
rows.value = response.users ?? [];
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin users";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminUserRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
editForm.id = row.id || "";
|
||||
editForm.email = row.email || "";
|
||||
editForm.username = row.username || "";
|
||||
editForm.password = "";
|
||||
editForm.role = row.role || "USER";
|
||||
editForm.planId = row.planId || "";
|
||||
editOpen.value = true;
|
||||
};
|
||||
|
||||
const openRoleDialog = (row: AdminUserRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
roleForm.id = row.id || "";
|
||||
roleForm.role = row.role || "USER";
|
||||
roleOpen.value = true;
|
||||
};
|
||||
|
||||
const openDeleteDialog = (row: AdminUserRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
deleteOpen.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!canCreate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.createAdminUser({
|
||||
email: createForm.email.trim(),
|
||||
username: normalizeOptional(createForm.username),
|
||||
password: createForm.password,
|
||||
role: createForm.role,
|
||||
planId: normalizeOptional(createForm.planId),
|
||||
});
|
||||
resetCreateForm();
|
||||
createOpen.value = false;
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to create user";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!canUpdate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.updateAdminUser({
|
||||
id: editForm.id,
|
||||
email: editForm.email.trim(),
|
||||
username: normalizeOptional(editForm.username),
|
||||
password: normalizeOptional(editForm.password),
|
||||
role: editForm.role,
|
||||
planId: normalizeOptional(editForm.planId),
|
||||
});
|
||||
editOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update user";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitRole = async () => {
|
||||
if (!canUpdateRole.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.updateAdminUserRole({
|
||||
id: roleForm.id,
|
||||
role: roleForm.role,
|
||||
});
|
||||
roleOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update role";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitDelete = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.deleteAdminUser({ id: selectedRow.value.id });
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to delete user";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadUsers);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Users"
|
||||
description="User management data from admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex justify-end">
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create user</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">ID</th>
|
||||
<th class="py-3 pr-4 font-medium">Username</th>
|
||||
<th class="py-3 pr-4 font-medium">Email</th>
|
||||
<th class="py-3 pr-4 font-medium">Role</th>
|
||||
<th class="py-3 pr-4 font-medium">Plan</th>
|
||||
<th class="py-3 pr-4 font-medium">Videos</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">Loading users...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">No users found.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.id }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.username || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.email }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.role || 'USER' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.planName || row.planId || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.videoCount ?? 0 }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="ghost" @click="openRoleDialog(row)">Role</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin user" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Email</label>
|
||||
<AppInput v-model="createForm.email" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Username</label>
|
||||
<AppInput v-model="createForm.username" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Role</label>
|
||||
<select v-model="createForm.role" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="role in roleOptions" :key="role" :value="role">{{ role }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Password</label>
|
||||
<AppInput v-model="createForm.password" type="password" placeholder="Minimum 6 characters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Plan ID</label>
|
||||
<AppInput v-model="createForm.planId" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="editOpen" title="Edit user" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Email</label>
|
||||
<AppInput v-model="editForm.email" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Username</label>
|
||||
<AppInput v-model="editForm.username" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Role</label>
|
||||
<select v-model="editForm.role" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="role in roleOptions" :key="role" :value="role">{{ role }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Reset password</label>
|
||||
<AppInput v-model="editForm.password" type="password" placeholder="Leave blank to keep current" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Plan ID</label>
|
||||
<AppInput v-model="editForm.planId" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canUpdate" @click="submitEdit">Save</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="roleOpen" title="Update user role" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Role</label>
|
||||
<select v-model="roleForm.role" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="role in roleOptions" :key="role" :value="role">{{ role }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canUpdateRole" @click="submitRole">Update role</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="deleteOpen" title="Delete user" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Delete <span class="font-medium">{{ selectedRow?.email || selectedRow?.id }}</span> and related data.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitDelete">Delete</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
354
src/routes/admin/Videos.vue
Normal file
354
src/routes/admin/Videos.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminVideoRow = any;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminVideoRow[]>([]);
|
||||
const selectedRow = ref<AdminVideoRow | null>(null);
|
||||
const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const statusOptions = ["UPLOADED", "PROCESSING", "READY", "FAILED"];
|
||||
|
||||
const createForm = reactive({
|
||||
userId: "",
|
||||
title: "",
|
||||
description: "",
|
||||
url: "",
|
||||
size: null as number | null,
|
||||
duration: null as number | null,
|
||||
format: "",
|
||||
status: "READY",
|
||||
adTemplateId: "",
|
||||
});
|
||||
|
||||
const editForm = reactive({
|
||||
id: "",
|
||||
userId: "",
|
||||
title: "",
|
||||
description: "",
|
||||
url: "",
|
||||
size: null as number | null,
|
||||
duration: null as number | null,
|
||||
format: "",
|
||||
status: "READY",
|
||||
adTemplateId: "",
|
||||
});
|
||||
|
||||
const normalizeOptional = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const normalizeNumber = (value: number | null) => (value == null ? undefined : value);
|
||||
|
||||
const canCreate = computed(() => createForm.userId.trim() && createForm.title.trim() && createForm.url.trim() && createForm.status.trim());
|
||||
const canUpdate = computed(() => editForm.id.trim() && editForm.userId.trim() && editForm.title.trim() && editForm.url.trim() && editForm.status.trim());
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.userId = "";
|
||||
createForm.title = "";
|
||||
createForm.description = "";
|
||||
createForm.url = "";
|
||||
createForm.size = null;
|
||||
createForm.duration = null;
|
||||
createForm.format = "";
|
||||
createForm.status = "READY";
|
||||
createForm.adTemplateId = "";
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
editOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const loadVideos = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminVideos({ page: 1, limit: 20 });
|
||||
rows.value = response.videos ?? [];
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin videos";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminVideoRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
editForm.id = row.id || "";
|
||||
editForm.userId = row.userId || "";
|
||||
editForm.title = row.title || "";
|
||||
editForm.description = row.description || "";
|
||||
editForm.url = row.url || "";
|
||||
editForm.size = row.size ?? null;
|
||||
editForm.duration = row.duration ?? null;
|
||||
editForm.format = row.format || "";
|
||||
editForm.status = row.status || "READY";
|
||||
editForm.adTemplateId = row.adTemplateId || "";
|
||||
editOpen.value = true;
|
||||
};
|
||||
|
||||
const openDeleteDialog = (row: AdminVideoRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
deleteOpen.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!canCreate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.createAdminVideo({
|
||||
userId: createForm.userId.trim(),
|
||||
title: createForm.title.trim(),
|
||||
description: normalizeOptional(createForm.description),
|
||||
url: createForm.url.trim(),
|
||||
size: normalizeNumber(createForm.size),
|
||||
duration: normalizeNumber(createForm.duration),
|
||||
format: normalizeOptional(createForm.format),
|
||||
status: createForm.status,
|
||||
adTemplateId: normalizeOptional(createForm.adTemplateId),
|
||||
});
|
||||
resetCreateForm();
|
||||
createOpen.value = false;
|
||||
await loadVideos();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to create video";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!canUpdate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.updateAdminVideo({
|
||||
id: editForm.id,
|
||||
userId: editForm.userId.trim(),
|
||||
title: editForm.title.trim(),
|
||||
description: normalizeOptional(editForm.description),
|
||||
url: editForm.url.trim(),
|
||||
size: normalizeNumber(editForm.size),
|
||||
duration: normalizeNumber(editForm.duration),
|
||||
format: normalizeOptional(editForm.format),
|
||||
status: editForm.status,
|
||||
adTemplateId: normalizeOptional(editForm.adTemplateId),
|
||||
});
|
||||
editOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadVideos();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update video";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitDelete = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.deleteAdminVideo({ id: selectedRow.value.id });
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadVideos();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to delete video";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadVideos);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Videos"
|
||||
description="Cross-user video list from admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex justify-end">
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create video</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">ID</th>
|
||||
<th class="py-3 pr-4 font-medium">Title</th>
|
||||
<th class="py-3 pr-4 font-medium">Owner</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Format</th>
|
||||
<th class="py-3 pr-4 font-medium">Size</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">Loading videos...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">No videos found.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.id }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.title }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.ownerEmail || row.userId }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.format || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.size ?? 0 }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin video" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="createForm.userId" placeholder="user-id" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Status</label>
|
||||
<select v-model="createForm.status" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Title</label>
|
||||
<AppInput v-model="createForm.title" placeholder="Video title" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Video URL</label>
|
||||
<AppInput v-model="createForm.url" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="createForm.description" rows="3" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Format</label>
|
||||
<AppInput v-model="createForm.format" placeholder="mp4" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Ad template ID</label>
|
||||
<AppInput v-model="createForm.adTemplateId" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Size</label>
|
||||
<AppInput v-model="createForm.size" type="number" placeholder="0" min="0" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Duration</label>
|
||||
<AppInput v-model="createForm.duration" type="number" placeholder="0" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="editOpen" title="Edit video" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="editForm.userId" placeholder="user-id" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Status</label>
|
||||
<select v-model="editForm.status" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Title</label>
|
||||
<AppInput v-model="editForm.title" placeholder="Video title" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Video URL</label>
|
||||
<AppInput v-model="editForm.url" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="editForm.description" rows="3" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Format</label>
|
||||
<AppInput v-model="editForm.format" placeholder="mp4" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Ad template ID</label>
|
||||
<AppInput v-model="editForm.adTemplateId" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Size</label>
|
||||
<AppInput v-model="editForm.size" type="number" placeholder="0" min="0" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Duration</label>
|
||||
<AppInput v-model="editForm.duration" type="number" placeholder="0" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canUpdate" @click="submitEdit">Save</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="deleteOpen" title="Delete video" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Delete video <span class="font-medium">{{ selectedRow?.title || selectedRow?.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitDelete">Delete</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
27
src/routes/admin/components/AdminPlaceholderTable.vue
Normal file
27
src/routes/admin/components/AdminPlaceholderTable.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
columns: string[];
|
||||
rows?: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th v-for="column in columns" :key="column" class="py-3 pr-4 font-medium">
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="i in rows ?? 5" :key="i" class="border-b border-gray-100">
|
||||
<td v-for="column in columns" :key="column" class="py-3 pr-4 text-gray-700">
|
||||
—
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
18
src/routes/admin/components/AdminSectionShell.vue
Normal file
18
src/routes/admin/components/AdminSectionShell.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from "@/components/dashboard/PageHeader.vue";
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<PageHeader :title="title" :description="description" />
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-6">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -28,7 +28,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { reactive } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
@@ -59,7 +59,7 @@ const onFormSubmit = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
client.auth.forgotPasswordCreate({ email: form.email })
|
||||
rpcClient.forgotPassword({ email: form.email })
|
||||
.then(() => {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
|
||||
@@ -20,11 +20,9 @@ const status = computed(() => String(route.query.status ?? 'error'));
|
||||
const reason = computed(() => String(route.query.reason ?? 'google_login_failed'));
|
||||
|
||||
const reasonMessages: Record<string, string> = {
|
||||
missing_state: 'Google login session is invalid. Please try again.',
|
||||
invalid_state: 'Google login session has expired. Please try again.',
|
||||
missing_code: 'Google did not return an authorization code.',
|
||||
access_denied: 'Google login was cancelled.',
|
||||
exchange_failed: 'Failed to sign in with Google.',
|
||||
exchange_failed: 'Failed to verify your Google sign-in. Please try again.',
|
||||
userinfo_failed: 'Failed to load your Google account information.',
|
||||
userinfo_parse_failed: 'Failed to read your Google account information.',
|
||||
missing_email: 'Your Google account did not provide an email address.',
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "vue-router";
|
||||
|
||||
type RouteData = RouteRecordRaw & {
|
||||
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
|
||||
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean; requiresAdmin?: boolean };
|
||||
children?: RouteData[];
|
||||
};
|
||||
const routes: RouteData[] = [
|
||||
@@ -217,6 +217,23 @@ const routes: RouteData[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
component: () => import("./admin/Layout.vue"),
|
||||
meta: { requiresAdmin: true },
|
||||
redirect: { name: "admin-overview" },
|
||||
children: [
|
||||
{ path: "overview", name: "admin-overview", component: () => import("./admin/Overview.vue") },
|
||||
{ path: "users", name: "admin-users", component: () => import("./admin/Users.vue") },
|
||||
{ path: "videos", name: "admin-videos", component: () => import("./admin/Videos.vue") },
|
||||
{ path: "payments", name: "admin-payments", component: () => import("./admin/Payments.vue") },
|
||||
{ path: "plans", name: "admin-plans", component: () => import("./admin/Plans.vue") },
|
||||
{ path: "ad-templates", name: "admin-ad-templates", component: () => import("./admin/AdTemplates.vue") },
|
||||
{ path: "jobs", name: "admin-jobs", component: () => import("./admin/Jobs.vue") },
|
||||
{ path: "agents", name: "admin-agents", component: () => import("./admin/Agents.vue") },
|
||||
{ path: "logs", name: "admin-logs", component: () => import("./admin/Logs.vue") },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -254,6 +271,17 @@ const createAppRouter = () => {
|
||||
return { name: "login" };
|
||||
}
|
||||
}
|
||||
|
||||
if (to.matched.some((record) => record.meta.requiresAdmin)) {
|
||||
if (!auth.user) {
|
||||
return { name: "login" };
|
||||
}
|
||||
|
||||
const role = String(auth.user.role || "").toLowerCase();
|
||||
if (role !== "admin") {
|
||||
return { name: "overview" };
|
||||
}
|
||||
}
|
||||
});
|
||||
router.afterEach(() => {
|
||||
loading.finish()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="tsx">
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { useUsageQuery } from '@/composables/useUsageQuery';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
@@ -23,16 +24,8 @@ const statsLoading = computed(() => recentVideosLoading.value || (isUsagePending
|
||||
const fetchDashboardData = async () => {
|
||||
recentVideosLoading.value = true;
|
||||
try {
|
||||
const response = await client.videos.videosList({ page: 1, limit: 5 }, { baseUrl: '/r' });
|
||||
const body = response.data as any;
|
||||
|
||||
const videos = Array.isArray(body?.data?.videos)
|
||||
? body.data.videos
|
||||
: Array.isArray(body?.videos)
|
||||
? body.videos
|
||||
: [];
|
||||
|
||||
recentVideos.value = videos;
|
||||
const response = await rpcClient.listVideos({ page: 1, limit: 5 });
|
||||
recentVideos.value = response.videos ?? [];
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch dashboard data:', err);
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ModelVideo } from '@/api/client';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||
import { formatDate, formatDuration } from '@/lib/utils';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
@@ -112,7 +112,7 @@ const getStatusClass = (status?: string) => {
|
||||
{{ formatDuration(video.duration) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatDate(video.created_at) }}
|
||||
{{ formatDate(video.createdAt) }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
@@ -38,12 +38,12 @@ interface VastTemplate {
|
||||
type AdTemplateApiItem = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
vast_tag_url?: string;
|
||||
ad_format?: 'pre-roll' | 'mid-roll' | 'post-roll';
|
||||
vastTagUrl?: string;
|
||||
adFormat?: 'pre-roll' | 'mid-roll' | 'post-roll';
|
||||
duration?: number | null;
|
||||
is_active?: boolean;
|
||||
is_default?: boolean;
|
||||
created_at?: string;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
const adFormatOptions = ['pre-roll', 'mid-roll', 'post-roll'] as const;
|
||||
@@ -68,21 +68,21 @@ const isMutating = computed(() => saving.value || deletingId.value !== null || t
|
||||
const canMarkAsDefaultInDialog = computed(() => !isFreePlan.value && (!editingTemplate.value || editingTemplate.value.enabled));
|
||||
|
||||
const mapTemplate = (item: AdTemplateApiItem): VastTemplate => ({
|
||||
id: item.id || `${item.name || 'template'}:${item.vast_tag_url || item.created_at || ''}`,
|
||||
id: item.id || `${item.name || 'template'}:${item.vastTagUrl || item.createdAt || ''}`,
|
||||
name: item.name || '',
|
||||
vastUrl: item.vast_tag_url || '',
|
||||
adFormat: item.ad_format || 'pre-roll',
|
||||
vastUrl: item.vastTagUrl || '',
|
||||
adFormat: item.adFormat || 'pre-roll',
|
||||
duration: typeof item.duration === 'number' ? item.duration : undefined,
|
||||
enabled: Boolean(item.is_active),
|
||||
isDefault: Boolean(item.is_default),
|
||||
createdAt: item.created_at || '',
|
||||
enabled: Boolean(item.isActive),
|
||||
isDefault: Boolean(item.isDefault),
|
||||
createdAt: item.createdAt || '',
|
||||
});
|
||||
|
||||
const { data: templatesSnapshot, error, isPending, refetch } = useQuery({
|
||||
key: () => ['settings', 'ad-templates'],
|
||||
query: async () => {
|
||||
const response = await client.adTemplates.adTemplatesList({ baseUrl: '/r' });
|
||||
return ((((response.data as any)?.data?.templates) || []) as AdTemplateApiItem[]).map(mapTemplate);
|
||||
const response = await rpcClient.listAdTemplates();
|
||||
return (response.templates || []).map(mapTemplate);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -161,11 +161,12 @@ const openEditDialog = (template: VastTemplate) => {
|
||||
|
||||
const buildRequestBody = (enabled = true) => ({
|
||||
name: formData.value.name.trim(),
|
||||
vast_tag_url: formData.value.vastUrl.trim(),
|
||||
ad_format: formData.value.adFormat,
|
||||
description: '',
|
||||
vastTagUrl: formData.value.vastUrl.trim(),
|
||||
adFormat: formData.value.adFormat,
|
||||
duration: formData.value.adFormat === 'mid-roll' ? formData.value.duration : undefined,
|
||||
is_active: enabled,
|
||||
is_default: enabled ? formData.value.isDefault : false,
|
||||
isActive: enabled,
|
||||
isDefault: enabled ? formData.value.isDefault : false,
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -213,11 +214,10 @@ const handleSave = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
if (editingTemplate.value) {
|
||||
await client.adTemplates.adTemplatesUpdate(
|
||||
editingTemplate.value.id,
|
||||
buildRequestBody(editingTemplate.value.enabled),
|
||||
{ baseUrl: '/r' },
|
||||
);
|
||||
await rpcClient.updateAdTemplate({
|
||||
id: editingTemplate.value.id,
|
||||
...buildRequestBody(editingTemplate.value.enabled),
|
||||
});
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.updatedSummary'),
|
||||
@@ -225,7 +225,7 @@ const handleSave = async () => {
|
||||
life: 3000,
|
||||
});
|
||||
} else {
|
||||
await client.adTemplates.adTemplatesCreate(buildRequestBody(true), { baseUrl: '/r' });
|
||||
await rpcClient.createAdTemplate(buildRequestBody(true));
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.createdSummary'),
|
||||
@@ -249,14 +249,16 @@ const handleToggle = async (template: VastTemplate, nextValue: boolean) => {
|
||||
|
||||
togglingId.value = template.id;
|
||||
try {
|
||||
await client.adTemplates.adTemplatesUpdate(template.id, {
|
||||
await rpcClient.updateAdTemplate({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
vast_tag_url: template.vastUrl,
|
||||
ad_format: template.adFormat,
|
||||
description: '',
|
||||
vastTagUrl: template.vastUrl,
|
||||
adFormat: template.adFormat,
|
||||
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
|
||||
is_active: nextValue,
|
||||
is_default: nextValue ? template.isDefault : false,
|
||||
}, { baseUrl: '/r' });
|
||||
isActive: nextValue,
|
||||
isDefault: nextValue ? template.isDefault : false,
|
||||
});
|
||||
|
||||
await refetchTemplates();
|
||||
toast.add({
|
||||
@@ -285,14 +287,16 @@ const handleSetDefault = async (template: VastTemplate) => {
|
||||
|
||||
defaultingId.value = template.id;
|
||||
try {
|
||||
await client.adTemplates.adTemplatesUpdate(template.id, {
|
||||
await rpcClient.updateAdTemplate({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
vast_tag_url: template.vastUrl,
|
||||
ad_format: template.adFormat,
|
||||
description: '',
|
||||
vastTagUrl: template.vastUrl,
|
||||
adFormat: template.adFormat,
|
||||
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
|
||||
is_active: template.enabled,
|
||||
is_default: true,
|
||||
}, { baseUrl: '/r' });
|
||||
isActive: template.enabled,
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
await refetchTemplates();
|
||||
toast.add({
|
||||
@@ -320,7 +324,7 @@ const handleDelete = (template: VastTemplate) => {
|
||||
accept: async () => {
|
||||
deletingId.value = template.id;
|
||||
try {
|
||||
await client.adTemplates.adTemplatesDelete(template.id, { baseUrl: '/r' });
|
||||
await rpcClient.deleteAdTemplate({ id: template.id });
|
||||
await refetchTemplates();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ModelPlan } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import type { PaymentHistoryItem as PaymentHistoryApiItem, Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
@@ -19,30 +20,10 @@ import { computed, ref, watch } from 'vue';
|
||||
const TERM_OPTIONS = [1, 3, 6, 12] as const;
|
||||
type UpgradePaymentMethod = 'wallet' | 'topup';
|
||||
|
||||
type PlansEnvelope = {
|
||||
data?: {
|
||||
plans?: ModelPlan[];
|
||||
} | ModelPlan[];
|
||||
};
|
||||
|
||||
type PaymentHistoryApiItem = {
|
||||
id?: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
status?: string;
|
||||
plan_name?: string;
|
||||
invoice_id?: string;
|
||||
kind?: string;
|
||||
term_months?: number;
|
||||
payment_method?: string;
|
||||
expires_at?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
type PaymentHistoryEnvelope = {
|
||||
data?: {
|
||||
payments?: PaymentHistoryApiItem[];
|
||||
};
|
||||
type InvoiceDownloadResponse = {
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type PaymentHistoryItem = {
|
||||
@@ -69,7 +50,7 @@ const { t, i18next } = useTranslation();
|
||||
|
||||
const { data: plansResponse, isLoading } = useQuery({
|
||||
key: () => ['billing-plans'],
|
||||
query: () => client.plans.plansList({ baseUrl: '/r' }),
|
||||
query: () => rpcClient.listPlans(),
|
||||
});
|
||||
const { data: usageSnapshot, refetch: refetchUsage } = useUsageQuery();
|
||||
|
||||
@@ -89,17 +70,7 @@ const purchaseTopupAmount = ref<number | null>(null);
|
||||
const purchaseLoading = ref(false);
|
||||
const purchaseError = ref<string | null>(null);
|
||||
|
||||
const plans = computed(() => {
|
||||
const body = plansResponse.value?.data as PlansEnvelope | undefined;
|
||||
const payload = body?.data;
|
||||
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (payload && typeof payload === 'object' && Array.isArray(payload.plans)) {
|
||||
return payload.plans;
|
||||
}
|
||||
|
||||
return [] as ModelPlan[];
|
||||
});
|
||||
const plans = computed(() => plansResponse.value?.plans || [] as ModelPlan[]);
|
||||
|
||||
const currentPlanId = computed(() => auth.user?.plan_id || undefined);
|
||||
const currentPlan = computed(() => plans.value.find(plan => plan.id === currentPlanId.value));
|
||||
@@ -109,11 +80,11 @@ const storageUsed = computed(() => usageSnapshot.value?.totalStorage ?? 0);
|
||||
const uploadsUsed = computed(() => usageSnapshot.value?.totalVideos ?? 0);
|
||||
const storageLimit = computed(() => {
|
||||
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
|
||||
return activePlan?.storage_limit || 10737418240;
|
||||
return activePlan?.storageLimit || 10737418240;
|
||||
});
|
||||
const uploadsLimit = computed(() => {
|
||||
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
|
||||
return activePlan?.upload_limit || 50;
|
||||
return activePlan?.uploadLimit || 50;
|
||||
});
|
||||
const storagePercentage = computed(() =>
|
||||
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100),
|
||||
@@ -189,9 +160,9 @@ const formatPaymentMethodLabel = (value?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storage_limit || 0) });
|
||||
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.duration_limit) });
|
||||
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.upload_limit || 0 });
|
||||
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storageLimit || 0) });
|
||||
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.durationLimit) });
|
||||
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.uploadLimit || 0 });
|
||||
|
||||
const getStatusStyles = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -254,25 +225,25 @@ const getApiErrorData = (error: unknown) => getApiErrorPayload(error)?.data || n
|
||||
const mapHistoryItem = (item: PaymentHistoryApiItem): PaymentHistoryItem => {
|
||||
const details: string[] = [];
|
||||
|
||||
if (item.kind !== 'wallet_topup' && item.term_months) {
|
||||
details.push(formatTermLabel(item.term_months));
|
||||
if (item.kind !== 'wallet_topup' && item.termMonths) {
|
||||
details.push(formatTermLabel(item.termMonths));
|
||||
}
|
||||
if (item.kind !== 'wallet_topup' && item.payment_method) {
|
||||
details.push(formatPaymentMethodLabel(item.payment_method));
|
||||
if (item.kind !== 'wallet_topup' && item.paymentMethod) {
|
||||
details.push(formatPaymentMethodLabel(item.paymentMethod));
|
||||
}
|
||||
if (item.kind !== 'wallet_topup' && item.expires_at) {
|
||||
details.push(t('settings.billing.history.validUntil', { date: formatHistoryDate(item.expires_at) }));
|
||||
if (item.kind !== 'wallet_topup' && item.expiresAt) {
|
||||
details.push(t('settings.billing.history.validUntil', { date: formatHistoryDate(item.expiresAt) }));
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id || '',
|
||||
date: formatHistoryDate(item.created_at),
|
||||
date: formatHistoryDate(item.createdAt),
|
||||
amount: item.amount || 0,
|
||||
plan: item.kind === 'wallet_topup'
|
||||
? t('settings.billing.walletTopup')
|
||||
: (item.plan_name || t('settings.billing.unknownPlan')),
|
||||
: (item.planName || t('settings.billing.unknownPlan')),
|
||||
status: normalizeHistoryStatus(item.status),
|
||||
invoiceId: item.invoice_id || '-',
|
||||
invoiceId: item.invoiceId || '-',
|
||||
currency: item.currency || 'USD',
|
||||
kind: item.kind || 'subscription',
|
||||
details,
|
||||
@@ -282,9 +253,8 @@ const mapHistoryItem = (item: PaymentHistoryApiItem): PaymentHistoryItem => {
|
||||
const loadPaymentHistory = async () => {
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
const response = await client.payments.historyList({ baseUrl: '/r' });
|
||||
const body = response.data as PaymentHistoryEnvelope | undefined;
|
||||
paymentHistory.value = (body?.data?.payments || []).map(mapHistoryItem);
|
||||
const response = await rpcClient.listPaymentHistory();
|
||||
paymentHistory.value = (response.payments || []).map(mapHistoryItem);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
paymentHistory.value = [];
|
||||
@@ -308,7 +278,7 @@ const refreshBillingState = async () => {
|
||||
void loadPaymentHistory();
|
||||
|
||||
const subscriptionSummary = computed(() => {
|
||||
const expiresAt = auth.user?.plan_expires_at;
|
||||
const expiresAt = auth.user?.planExpiresAt || auth.user?.plan_expires_at;
|
||||
const formattedDate = formatHistoryDate(expiresAt);
|
||||
|
||||
if (auth.user?.plan_id) {
|
||||
@@ -434,16 +404,16 @@ const submitUpgrade = async () => {
|
||||
try {
|
||||
const paymentMethod: UpgradePaymentMethod = selectedNeedsTopup.value ? selectedPaymentMethod.value : 'wallet';
|
||||
const payload: Record<string, any> = {
|
||||
plan_id: selectedPlan.value.id,
|
||||
term_months: selectedTermMonths.value,
|
||||
payment_method: paymentMethod,
|
||||
planId: selectedPlan.value.id,
|
||||
termMonths: selectedTermMonths.value,
|
||||
paymentMethod: paymentMethod,
|
||||
};
|
||||
|
||||
if (paymentMethod === 'topup') {
|
||||
payload.topup_amount = purchaseTopupAmount.value || selectedShortfall.value;
|
||||
payload.topupAmount = purchaseTopupAmount.value || selectedShortfall.value;
|
||||
}
|
||||
|
||||
await client.payments.paymentsCreate(payload, { baseUrl: '/r' });
|
||||
await rpcClient.createPayment(payload);
|
||||
await refreshBillingState();
|
||||
|
||||
toast.add({
|
||||
@@ -481,7 +451,7 @@ const submitUpgrade = async () => {
|
||||
const handleTopup = async (amount: number) => {
|
||||
topupLoading.value = true;
|
||||
try {
|
||||
await client.wallet.topupsCreate({ amount }, { baseUrl: '/r' });
|
||||
await rpcClient.topupWallet({ amount });
|
||||
await refreshBillingState();
|
||||
|
||||
toast.add({
|
||||
@@ -517,13 +487,15 @@ const handleDownloadInvoice = async (item: PaymentHistoryItem) => {
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await client.payments.invoiceList(item.id, { baseUrl: '/r', format: 'text' });
|
||||
const content = typeof response.data === 'string' ? response.data : '';
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const response = await rpcClient.downloadInvoice({ id: item.id }) as InvoiceDownloadResponse;
|
||||
const content = response.content || '';
|
||||
const contentType = response.contentType || 'text/plain;charset=utf-8';
|
||||
const filename = response.filename || `${item.invoiceId}.txt`;
|
||||
const blob = new Blob([content], { type: contentType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `${item.invoiceId}.txt`;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
|
||||
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
|
||||
@@ -32,7 +32,7 @@ const handleDeleteAccount = () => {
|
||||
accept: async () => {
|
||||
deletingAccount.value = true;
|
||||
try {
|
||||
await client.me.deleteMe({ baseUrl: '/r' });
|
||||
await rpcClient.deleteMe();
|
||||
|
||||
auth.$reset();
|
||||
toast.add({
|
||||
@@ -66,7 +66,7 @@ const handleClearData = () => {
|
||||
accept: async () => {
|
||||
clearingData.value = true;
|
||||
try {
|
||||
await client.me.clearDataCreate({ baseUrl: '/r' });
|
||||
await rpcClient.clearMyData();
|
||||
|
||||
await auth.fetchMe();
|
||||
toast.add({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
@@ -64,8 +64,8 @@ const mapDomainItem = (item: DomainApiItem): DomainItem => ({
|
||||
const { data: domainsSnapshot, error, isPending, refetch } = useQuery({
|
||||
key: () => ['settings', 'domains'],
|
||||
query: async () => {
|
||||
const response = await client.domains.domainsList({ baseUrl: '/r' });
|
||||
return ((((response.data as any)?.data?.domains) || []) as DomainApiItem[]).map(mapDomainItem);
|
||||
const response = await rpcClient.listDomains();
|
||||
return (response.domains || []).map(mapDomainItem);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -126,9 +126,9 @@ const handleAddDomain = async () => {
|
||||
|
||||
adding.value = true;
|
||||
try {
|
||||
await client.domains.domainsCreate({
|
||||
await rpcClient.createDomain({
|
||||
name: domainName,
|
||||
}, { baseUrl: '/r' });
|
||||
});
|
||||
|
||||
await refetchDomains();
|
||||
closeAddDialog();
|
||||
@@ -178,7 +178,7 @@ const handleRemoveDomain = (domain: DomainItem) => {
|
||||
accept: async () => {
|
||||
removingId.value = domain.id;
|
||||
try {
|
||||
await client.domains.domainsDelete(domain.id, { baseUrl: '/r' });
|
||||
await rpcClient.deleteDomain({ id: domain.id });
|
||||
await refetchDomains();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import BellIcon from '@/components/icons/BellIcon.vue';
|
||||
@@ -90,9 +90,8 @@ const handleSave = async () => {
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await client.settings.preferencesUpdate(
|
||||
await rpcClient.updatePreferences(
|
||||
toNotificationPreferencesPayload(notificationSettings.value),
|
||||
{ baseUrl: '/r' },
|
||||
);
|
||||
await refetchPreferences();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
@@ -100,9 +100,8 @@ const handleSave = async () => {
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await client.settings.preferencesUpdate(
|
||||
await rpcClient.updatePreferences(
|
||||
toPlayerPreferencesPayload(playerSettings.value),
|
||||
{ baseUrl: '/r' },
|
||||
);
|
||||
await refetch();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelPlan } from '@/api/client';
|
||||
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
|
||||
|
||||
@@ -44,7 +44,7 @@ const emit = defineEmits<{
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="plan in plans.sort((a,b) => a.price - b.price)"
|
||||
v-for="plan in plans.sort((a,b) => (a.price || 0) - (b.price || 0))"
|
||||
:key="plan.id"
|
||||
:class="[
|
||||
'border rounded-lg p-4 hover:bg-muted/30 transition-all flex flex-col',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
@@ -21,10 +22,9 @@ const { t } = useTranslation();
|
||||
const fetchVideo = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await client.videos.videosDetail(props.videoId, { baseUrl: '/r' });
|
||||
const videoData = (response.data as any)?.data?.video || (response.data as any)?.data;
|
||||
if (videoData) {
|
||||
video.value = videoData;
|
||||
const response = await rpcClient.getVideo({ id: props.videoId });
|
||||
if (response.video) {
|
||||
video.value = response.video;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch video:', error);
|
||||
@@ -44,8 +44,8 @@ const baseUrl = computed(() => typeof window !== 'undefined' ? window.location.o
|
||||
const shareLinks = computed(() => {
|
||||
if (!video.value) return [];
|
||||
const v = video.value;
|
||||
const playbackPath = v.url || `/play/index/${v.id}`;
|
||||
const playbackUrl = playbackPath.startsWith('http') ? playbackPath : `${baseUrl.value}${playbackPath}`;
|
||||
const playbackPath = v.url || '';
|
||||
const playbackUrl = playbackPath.startsWith('http') ? playbackPath : `${baseUrl.value}/${playbackPath.replace(/^\/+/, '')}`;
|
||||
return [
|
||||
{
|
||||
key: 'embed',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ManualAdTemplate, type ModelVideo } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import type { AdTemplate as ManualAdTemplate, Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
@@ -53,9 +54,8 @@ const subtitleForm = ref({
|
||||
const fetchAdTemplates = async () => {
|
||||
loadingTemplates.value = true;
|
||||
try {
|
||||
const response = await client.adTemplates.adTemplatesList({ baseUrl: '/r' });
|
||||
const items = ((response.data as any)?.data?.templates || []) as ManualAdTemplate[];
|
||||
adTemplates.value = items;
|
||||
const response = await rpcClient.listAdTemplates();
|
||||
adTemplates.value = response.templates ?? [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch ad templates:', error);
|
||||
} finally {
|
||||
@@ -66,17 +66,15 @@ const fetchAdTemplates = async () => {
|
||||
const fetchVideo = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await client.videos.videosDetail(props.videoId, { baseUrl: '/r' });
|
||||
const data = (response.data as any)?.data;
|
||||
const videoData = data?.video || data;
|
||||
const adConfig = data?.ad_config as AdConfigPayload | undefined;
|
||||
const response = await rpcClient.getVideo({ id: props.videoId });
|
||||
const videoData = response.video;
|
||||
|
||||
if (videoData) {
|
||||
video.value = videoData;
|
||||
currentAdConfig.value = adConfig || null;
|
||||
currentAdConfig.value = null;
|
||||
form.value = {
|
||||
title: videoData.title || '',
|
||||
adTemplateId: adConfig?.ad_template_id || '',
|
||||
adTemplateId: '',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -104,26 +102,25 @@ const onFormSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
const response = await rpcClient.updateVideo({
|
||||
id: props.videoId,
|
||||
title: form.value.title,
|
||||
};
|
||||
description: video.value?.description || '',
|
||||
url: video.value?.url,
|
||||
size: video.value?.size,
|
||||
duration: video.value?.duration,
|
||||
format: video.value?.format,
|
||||
status: video.value?.status,
|
||||
});
|
||||
|
||||
if (!isFreePlan.value) {
|
||||
payload.ad_template_id = form.value.adTemplateId || '';
|
||||
}
|
||||
|
||||
const response = await client.videos.videosUpdate(props.videoId, payload as any, { baseUrl: '/r' });
|
||||
|
||||
const data = (response.data as any)?.data;
|
||||
const updatedVideo = data?.video as ModelVideo | undefined;
|
||||
const updatedAdConfig = data?.ad_config as AdConfigPayload | undefined;
|
||||
const updatedVideo = response.video as ModelVideo | undefined;
|
||||
|
||||
if (updatedVideo) {
|
||||
video.value = updatedVideo;
|
||||
currentAdConfig.value = updatedAdConfig || null;
|
||||
currentAdConfig.value = null;
|
||||
form.value = {
|
||||
title: updatedVideo.title || '',
|
||||
adTemplateId: updatedAdConfig?.ad_template_id || '',
|
||||
adTemplateId: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import { createStaticVNode, computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
@@ -47,15 +48,15 @@ const fetchVideos = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await client.videos.videosList({
|
||||
const response = await rpcClient.listVideos({
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
search: searchQuery.value || undefined,
|
||||
status: selectedStatus.value !== 'all' ? selectedStatus.value : undefined,
|
||||
} as any, { baseUrl: '/r' });
|
||||
});
|
||||
|
||||
videos.value = ((response.data as any)?.data?.videos ?? []) as ModelVideo[];
|
||||
total.value = (response.data as any)?.data?.total ?? 0;
|
||||
videos.value = response.videos ?? [];
|
||||
total.value = response.total ?? 0;
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
error.value = err?.response?.data?.message || err?.message || t('video.page.retry');
|
||||
@@ -87,7 +88,7 @@ const deleteSelectedVideos = async () => {
|
||||
selectedVideos.value
|
||||
.map(v => v.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.map(id => client.videos.videosDelete(id, { baseUrl: '/r' }))
|
||||
.map(id => rpcClient.deleteVideo({ id }))
|
||||
);
|
||||
selectedVideos.value = [];
|
||||
await fetchVideos();
|
||||
@@ -106,7 +107,7 @@ const deleteVideo = async (videoId?: string) => {
|
||||
if (!videoId || !confirm(t('video.page.deleteSingleConfirm'))) return;
|
||||
|
||||
try {
|
||||
await client.videos.videosDelete(videoId, { baseUrl: '/r' });
|
||||
await rpcClient.deleteVideo({ id: videoId });
|
||||
selectedVideos.value = selectedVideos.value.filter(v => v.id !== videoId);
|
||||
await fetchVideos();
|
||||
} catch (err) {
|
||||
|
||||
@@ -42,7 +42,7 @@ import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import EllipsisVerticalIcon from '@/components/icons/EllipsisVerticalIcon.vue';
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
@@ -107,7 +107,7 @@ const handleCopyLink = async () => {
|
||||
const handleDownload = () => {
|
||||
if (props.video.id) {
|
||||
const link = document.createElement('a');
|
||||
link.href = props.video.hls_path || videoUrl.value;
|
||||
link.href = props.video.url?.startsWith('http') ? props.video.url : videoUrl.value;
|
||||
link.download = props.video.title || 'video';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { formatDate, formatDuration, getStatusSeverity } from '@/lib/utils';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import CardPopover from './CardPopover.vue';
|
||||
@@ -96,7 +96,7 @@ const toggleSelection = (video: ModelVideo) => {
|
||||
<p class="text-xs text-gray-500 mb-3 line-clamp-1 h-4">{{ video.description || t('video.table.noDescription') }}
|
||||
</p>
|
||||
<div class="text-xs text-gray-400 mt-auto">
|
||||
{{ formatDate(video.created_at) }}
|
||||
{{ formatDate(video.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
@@ -120,7 +120,7 @@ const isSelected = (video: ModelVideo) =>
|
||||
<span class="text-sm text-gray-500">{{ formatBytes(data.size) }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-sm text-gray-500">{{ formatDate(data.created_at, true) }}</span>
|
||||
<span class="text-sm text-gray-500">{{ formatDate(data.createdAt, true) }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-0.5">
|
||||
|
||||
Reference in New Issue
Block a user