This commit is contained in:
che
2026-07-23 09:31:01 +05:00
commit 4cfc5d6ae6
64 changed files with 9779 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
const activeTab = computed({
get() {
return route.path.startsWith("/users") ? "/users" : "/tasks";
},
set(path: string) {
if (path !== route.path) {
router.push(path);
}
},
});
const title = computed(() => String(route.meta.title ?? "EWA Mobile"));
const showBack = computed(() => Boolean(route.meta.back));
</script>
<template>
<van-nav-bar :title="title" :left-arrow="showBack" fixed placeholder @click-left="router.back()" />
<main class="page">
<router-view />
</main>
<van-tabbar v-model="activeTab" route placeholder safe-area-inset-bottom>
<van-tabbar-item to="/tasks" name="/tasks" icon="todo-list-o">Задачи</van-tabbar-item>
<van-tabbar-item to="/users" name="/users" icon="friends-o">Пользователи</van-tabbar-item>
</van-tabbar>
</template>
<style>
:root {
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: #172033;
background: #f7f8fa;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: #f7f8fa;
}
.page {
box-sizing: border-box;
width: min(760px, 100%);
margin: 0 auto;
padding: 24px 14px 40px;
}
.hero {
margin: 12px 2px 18px;
}
.eyebrow {
margin: 0 0 6px;
color: #1989fa;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2rem, 9vw, 4rem);
line-height: 1;
}
.subtitle {
margin: 12px 0 0;
color: #646566;
}
.notice {
margin-bottom: 12px;
border-radius: 14px;
}
.card {
overflow: hidden;
margin-bottom: 14px;
border-radius: 18px;
background: #fff;
box-shadow: 0 12px 40px rgba(36, 42, 56, 0.08);
}
.form-actions {
padding: 14px 16px 16px;
}
.list-card {
padding: 14px 0 16px;
}
.list-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px 12px;
}
.list-actions {
display: flex;
align-items: center;
gap: 8px;
}
.stacked-actions {
display: grid;
gap: 10px;
}
h2 {
margin: 0;
font-size: 1.1rem;
}
.state {
justify-content: center;
padding: 34px 0;
}
.detail-card {
padding: 14px 0 16px;
}
.detail-actions {
padding: 16px 16px 0;
}
.employee-popup {
box-sizing: border-box;
max-height: 78vh;
padding: 16px 0 22px;
}
.employee-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px 12px;
}
.employee-avatar {
margin-right: 10px;
}
</style>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { employeeApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
const selectedEmployeeId = defineModel<number>({ required: true });
const search = ref("");
const showSelector = ref(false);
const {
items: employees,
loading,
load: loadEmployees,
} = useModelApi(employeeApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить сотрудников",
});
const filteredEmployees = computed(() => {
const value = search.value.trim().toLowerCase();
if (!value) {
return employees.value;
}
return employees.value.filter((employee) => employee.name.toLowerCase().includes(value));
});
const selectedEmployee = computed(() =>
employees.value.find((employee) => employee.id === selectedEmployeeId.value),
);
const selectedEmployeeName = computed(() => selectedEmployee.value?.name ?? "не указан");
function openSelector() {
search.value = "";
showSelector.value = true;
}
function selectEmployee(id: number) {
selectedEmployeeId.value = id;
showSelector.value = false;
}
onMounted(loadEmployees);
</script>
<template>
<button class="employee-select" type="button" @click="openSelector">
<span>{{ selectedEmployeeName }}</span>
<van-icon name="arrow" />
</button>
<van-popup v-model:show="showSelector" round position="bottom" class="employee-popup">
<div class="employee-popup-header">
<h2>Выберите сотрудника</h2>
<van-button size="small" type="primary" plain @click="selectEmployee(0)">Не указан</van-button>
</div>
<van-search v-model="search" placeholder="Поиск по имени" />
<van-loading v-if="loading" class="employee-state" type="spinner">Загрузка...</van-loading>
<template v-else>
<van-cell-group inset>
<van-cell
v-for="employee in filteredEmployees"
:key="employee.id"
:title="employee.name"
:label="`ID: ${employee.id}`"
clickable
center
@click="selectEmployee(employee.id)"
>
<template #icon>
<van-image class="employee-avatar" round width="36" height="36" :src="employee.avatar" />
</template>
<template #right-icon>
<van-icon v-if="selectedEmployeeId === employee.id" name="success" color="#1989fa" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="filteredEmployees.length === 0" description="Сотрудники не найдены" />
</template>
</van-popup>
</template>
<style scoped>
.employee-popup {
min-height: 55vh;
padding: 18px 0 28px;
}
.employee-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 18px 10px;
}
.employee-popup-header h2 {
margin: 0;
font-size: 18px;
}
.employee-avatar {
margin-right: 10px;
}
.employee-state {
display: flex;
justify-content: center;
padding: 36px 0;
}
.employee-select {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
width: 100%;
border: 0;
padding: 0;
color: #323233;
background: transparent;
font: inherit;
line-height: 24px;
text-align: right;
}
.employee-select .van-icon {
color: #969799;
font-size: 16px;
}
</style>
+103
View File
@@ -0,0 +1,103 @@
import { ref, shallowRef } from "vue";
import { showToast } from "vant";
import type { BaseEntity, ListParams, ModelApi } from "../generated/api_client";
interface UseModelApiOptions<Params> {
defaultListParams?: Params;
loadErrorMessage?: string;
retrieveErrorMessage?: string;
createErrorMessage?: string;
showErrorToast?: boolean;
}
export function useModelApi<
T extends BaseEntity,
CreateDTO = Partial<T>,
UpdateDTO = Partial<T>,
Params extends ListParams = ListParams,
>(api: ModelApi<T, CreateDTO, UpdateDTO, Params>, options: UseModelApiOptions<Params> = {}) {
const items = shallowRef<T[]>([]);
const item = shallowRef<T | null>(null);
const loading = ref(false);
const loadingItem = ref(false);
const creating = ref(false);
const error = ref("");
async function load(params?: Params) {
loading.value = true;
error.value = "";
try {
const response = await api.list(params ?? options.defaultListParams);
items.value = response.results;
} catch (err) {
handleError(err, options.loadErrorMessage ?? "Не удалось загрузить данные");
} finally {
loading.value = false;
}
}
async function create(payload: CreateDTO) {
creating.value = true;
error.value = "";
try {
const item = await api.create(payload);
items.value = [...items.value, item];
return item;
} catch (err) {
handleError(err, options.createErrorMessage ?? "Не удалось создать объект");
return null;
} finally {
creating.value = false;
}
}
async function retrieve(id: number) {
loadingItem.value = true;
error.value = "";
try {
item.value = await api.retrieve(id);
return item.value;
} catch (err) {
handleError(err, options.retrieveErrorMessage ?? "Не удалось загрузить объект");
return null;
} finally {
loadingItem.value = false;
}
}
function handleError(err: unknown, fallback: string) {
const message = errorMessage(err, fallback);
error.value = message;
if (options.showErrorToast !== false) {
showToast(message);
}
}
return {
items,
item,
loading,
loadingItem,
creating,
error,
load,
retrieve,
create,
};
}
function errorMessage(err: unknown, fallback: string) {
if (typeof err === "object" && err && "detail" in err) {
return String((err as { detail: unknown }).detail);
}
if (err instanceof Error) {
return err.message;
}
return fallback;
}
+24
View File
@@ -0,0 +1,24 @@
import { createModelApi } from "./api_client";
import type {
Employee,
EmployeeCreate,
EmployeeUpdate,
EmployeeListParams,
Message,
MessageCreate,
MessageUpdate,
MessageListParams,
Task,
TaskCreate,
TaskUpdate,
TaskListParams,
User,
UserCreate,
UserUpdate,
UserListParams,
} from "./models";
export const employeeApi = createModelApi<Employee, EmployeeCreate, EmployeeUpdate, EmployeeListParams>("employee");
export const messageApi = createModelApi<Message, MessageCreate, MessageUpdate, MessageListParams>("message");
export const taskApi = createModelApi<Task, TaskCreate, TaskUpdate, TaskListParams>("task");
export const userApi = createModelApi<User, UserCreate, UserUpdate, UserListParams>("users");
+86
View File
@@ -0,0 +1,86 @@
import { invoke } from "@tauri-apps/api/core";
export interface BaseEntity {
id?: number;
}
export interface ListParams {
ordering?: string;
limit?: number;
offset?: number;
[key: string]: string | number | boolean | null | undefined;
}
export interface PaginatedResponse<T> {
count: number;
results: T[];
}
export interface ApiError {
code: string;
detail: string;
}
export interface ModelApi<
T extends BaseEntity,
CreateDTO = Partial<T>,
UpdateDTO = Partial<T>,
Params extends ListParams = ListParams,
> {
list: (params?: Params) => Promise<PaginatedResponse<T>>;
retrieve: (id: number) => Promise<T>;
create: (payload: CreateDTO) => Promise<T>;
update: (id: number, payload: UpdateDTO) => Promise<T>;
remove: (id: number) => Promise<{ deleted: true }>;
}
export function createModelApi<
T extends BaseEntity,
CreateDTO = Partial<T>,
UpdateDTO = Partial<T>,
Params extends ListParams = ListParams,
>(resource: string): ModelApi<T, CreateDTO, UpdateDTO, Params> {
return {
list(params) {
return invoke<PaginatedResponse<T>>("che_api", {
request: { resource, action: "list", params: serializeListParams(params) },
});
},
retrieve(id) {
return invoke<T>("che_api", {
request: { resource, action: "retrieve", id },
});
},
create(payload) {
return invoke<T>("che_api", {
request: { resource, action: "create", payload },
});
},
update(id, payload) {
return invoke<T>("che_api", {
request: { resource, action: "update", id, payload },
});
},
remove(id) {
return invoke<{ deleted: true }>("che_api", {
request: { resource, action: "delete", id },
});
},
};
}
function serializeListParams(params?: ListParams) {
const serialized: Record<string, string> = {};
for (const [key, value] of Object.entries(params ?? {})) {
if (value !== null && value !== undefined) {
serialized[key] = String(value);
}
}
return serialized;
}
+95
View File
@@ -0,0 +1,95 @@
import type { ListParams } from "./api_client";
export interface Employee {
id: number;
name: string;
avatar: string;
}
export interface EmployeeCreate {
name: string;
avatar: string;
}
export interface EmployeeUpdate {
name?: string;
avatar?: string;
}
export interface EmployeeListParams extends ListParams {
id?: number;
name?: string;
name__contains?: string;
}
export interface Message {
id: number;
task_id: number;
employee_id: number;
text: string;
}
export interface MessageCreate {
task_id: number;
employee_id: number;
text: string;
}
export interface MessageUpdate {
task_id?: number;
employee_id?: number;
text?: string;
}
export interface MessageListParams extends ListParams {
id?: number;
task_id?: number;
employee_id?: number;
}
export interface Task {
id: number;
name: string;
author_id: number | null;
responsible_id: number | null;
}
export interface TaskCreate {
name: string;
author_id?: number | null;
responsible_id?: number | null;
}
export interface TaskUpdate {
name?: string;
author_id?: number | null;
responsible_id?: number | null;
}
export interface TaskListParams extends ListParams {
id?: number;
name?: string;
name__contains?: string;
author_id?: number | null;
responsible_id?: number | null;
}
export interface User {
id: number;
name: string;
}
export interface UserCreate {
name: string;
}
export interface UserUpdate {
name?: string;
}
export interface UserListParams extends ListParams {
id?: number;
name?: string;
name__contains?: string;
}
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from "vue";
import Vant from "vant";
import "vant/lib/index.css";
import App from "./App.vue";
import { router } from "./router";
createApp(App).use(router).use(Vant).mount("#app");
+39
View File
@@ -0,0 +1,39 @@
import { createRouter, createWebHashHistory } from "vue-router";
import TaskCreateView from "../views/TaskCreateView.vue";
import TaskDetailView from "../views/TaskDetailView.vue";
import TasksView from "../views/TasksView.vue";
import UsersView from "../views/UsersView.vue";
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: "/",
redirect: "/tasks",
},
{
path: "/tasks",
name: "tasks",
component: TasksView,
meta: { title: "Задачи" },
},
{
path: "/tasks/new",
name: "task-create",
component: TaskCreateView,
meta: { title: "Новая задача", back: true },
},
{
path: "/tasks/:id",
name: "task-detail",
component: TaskDetailView,
meta: { title: "Детали задачи", back: true },
},
{
path: "/users",
name: "users",
component: UsersView,
meta: { title: "Пользователи" },
},
],
});
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { showToast } from "vant";
import { taskApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
import EmployeeSelect from "../components/EmployeeSelect.vue";
const router = useRouter();
const newTaskName = ref("");
const selectedAuthorId = ref(0);
const selectedResponsibleId = ref(0);
const {
creating,
error,
create: createTaskApi,
} = useModelApi(taskApi, {
createErrorMessage: "Не удалось создать задачу",
});
async function createTask() {
const name = newTaskName.value.trim();
if (!name) {
showToast("Введите название задачи");
return;
}
const task = await createTaskApi({
name,
author_id: selectedAuthorId.value || null,
responsible_id: selectedResponsibleId.value || null,
});
if (task) {
showToast("Задача создана");
router.push("/tasks");
}
}
</script>
<template>
<section class="hero">
<p class="eyebrow">New task</p>
<h1>Создать задачу</h1>
<p class="subtitle">Заполните название и при необходимости выберите автора и ответственного.</p>
</section>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<van-form class="card" @submit="createTask">
<van-field
v-model="newTaskName"
name="name"
label="Название"
placeholder="Например: Купить молоко"
clearable
:disabled="creating"
/>
<van-field label="Автор">
<template #input>
<EmployeeSelect v-model="selectedAuthorId" />
</template>
</van-field>
<van-field label="Ответственный">
<template #input>
<EmployeeSelect v-model="selectedResponsibleId" />
</template>
</van-field>
<div class="form-actions stacked-actions">
<van-button
block
round
type="primary"
native-type="submit"
:loading="creating"
:disabled="!newTaskName.trim()"
>
Создать задачу
</van-button>
<van-button block round plain type="default" @click="router.back()">Отмена</van-button>
</div>
</van-form>
</template>
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { showToast } from "vant";
import { employeeApi, messageApi, taskApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
import EmployeeSelect from "../components/EmployeeSelect.vue";
const route = useRoute();
const router = useRouter();
const taskId = computed(() => Number(route.params.id));
const messageText = ref("");
const selectedEmployeeId = ref(0);
const {
item: task,
loadingItem,
error,
retrieve: loadTask,
} = useModelApi(taskApi, {
retrieveErrorMessage: "Не удалось загрузить задачу",
});
const {
items: messages,
loading: loadingMessages,
creating: sendingMessage,
error: messageError,
load: loadMessages,
create: createMessageApi,
} = useModelApi(messageApi, {
loadErrorMessage: "Не удалось загрузить сообщения",
createErrorMessage: "Не удалось отправить сообщение",
});
const { items: employees, load: loadEmployees } = useModelApi(employeeApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить сотрудников",
});
async function sendMessage() {
if (!Number.isFinite(taskId.value)) {
showToast("Некорректный ID задачи");
return;
}
const text = messageText.value.trim();
if (!text) {
showToast("Введите сообщение");
return;
}
if (!selectedEmployeeId.value) {
showToast("Выберите сотрудника");
return;
}
const message = await createMessageApi({
task_id: taskId.value,
employee_id: selectedEmployeeId.value,
text,
});
if (message) {
messageText.value = "";
await loadMessages({ task_id: taskId.value });
showToast("Сообщение отправлено");
}
}
function employeeName(id: number) {
return employees.value.find((employee) => employee.id === id)?.name ?? `#${id}`;
}
function employeeAvatar(id: number) {
return employees.value.find((employee) => employee.id === id)?.avatar ?? "";
}
onMounted(() => {
if (Number.isFinite(taskId.value)) {
loadTask(taskId.value);
loadMessages({ task_id: taskId.value });
}
loadEmployees();
});
</script>
<template>
<section class="hero detail-hero">
<p class="eyebrow">Task details</p>
<h1>Детали</h1>
<p class="subtitle">Просмотр задачи и диалог сотрудников.</p>
</section>
<van-notice-bar
v-if="error || messageError"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error || messageError"
/>
<section class="card detail-card">
<van-loading v-if="loadingItem" class="state" type="spinner">Загрузка...</van-loading>
<van-empty v-else-if="!task" description="Задача не найдена" />
<template v-else>
<van-cell-group inset>
<van-cell title="ID" :value="task.id" />
<van-cell title="Название" :value="task.name" />
<van-cell title="Автор" :value="task.author_id ?? 'не указан'" />
<van-cell title="Ответственный" :value="task.responsible_id ?? 'не указан'" />
</van-cell-group>
</template>
</section>
<section class="card messages-card">
<div class="list-title">
<h2>Диалог</h2>
<van-button
size="small"
plain
type="primary"
:loading="loadingMessages"
@click="loadMessages({ task_id: taskId })"
>
Обновить
</van-button>
</div>
<van-loading v-if="loadingMessages && messages.length === 0" class="state" type="spinner">
Загрузка сообщений...
</van-loading>
<van-empty v-else-if="messages.length === 0" description="Сообщений пока нет" />
<div v-else class="message-list">
<article v-for="message in messages" :key="message.id" class="message-item">
<van-image
class="message-avatar"
round
width="40"
height="40"
:src="employeeAvatar(message.employee_id)"
/>
<div class="message-bubble">
<div class="message-author">{{ employeeName(message.employee_id) }}</div>
<div class="message-text">{{ message.text }}</div>
</div>
</article>
</div>
</section>
<van-form class="card" @submit="sendMessage">
<van-field
name="employee_id"
label="Отправитель"
placeholder="Выберите сотрудника"
>
<template #input>
<EmployeeSelect v-model="selectedEmployeeId" />
</template>
</van-field>
<van-field
v-model="messageText"
name="text"
label="Сообщение"
type="textarea"
rows="2"
autosize
clearable
placeholder="Напишите сообщение"
:disabled="sendingMessage"
/>
<div class="form-actions stacked-actions">
<van-button
block
round
type="primary"
native-type="submit"
:loading="sendingMessage"
:disabled="!Number.isFinite(taskId) || !messageText.trim() || !selectedEmployeeId"
>
Отправить сообщение
</van-button>
<van-button block round type="primary" plain @click="router.back()">Назад</van-button>
</div>
</van-form>
</template>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useRouter } from "vue-router";
import { employeeApi, taskApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
const router = useRouter();
const {
items: tasks,
loading,
error,
load: loadTasks,
} = useModelApi(taskApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить задачи",
});
const { items: employees, load: loadEmployees } = useModelApi(employeeApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить сотрудников",
});
function employeeName(id: number | null) {
if (id === null || id === 0) {
return "не указан";
}
return employees.value.find((employee) => employee.id === id)?.name ?? `#${id}`;
}
onMounted(() => {
loadTasks();
loadEmployees();
});
</script>
<template>
<section class="hero">
<p class="eyebrow">Task manager</p>
<h1>Мои задачи</h1>
<p class="subtitle">Данные загружаются через `che_api` и отображаются компонентами Vant.</p>
</section>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<section class="card list-card">
<div class="list-title">
<h2>Список</h2>
<div class="list-actions">
<van-button size="small" plain type="primary" :loading="loading" @click="loadTasks()">
Обновить
</van-button>
<van-button size="small" round type="primary" icon="plus" @click="router.push('/tasks/new')" />
</div>
</div>
<van-loading v-if="loading && tasks.length === 0" class="state" type="spinner">
Загрузка...
</van-loading>
<van-empty v-else-if="tasks.length === 0" description="Задач пока нет" />
<van-cell-group v-else inset>
<van-cell
v-for="task in tasks"
:key="task.id"
:title="task.name"
center
is-link
@click="router.push(`/tasks/${task.id}`)"
>
<template #label>
ID: {{ task.id }} · Автор: {{ employeeName(task.author_id) }} · Ответственный:
{{ employeeName(task.responsible_id) }}
</template>
<template #right-icon>
<van-tag plain type="primary">Task</van-tag>
</template>
</van-cell>
</van-cell-group>
</section>
</template>
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { userApi } from "../generated/api";
import { useModelApi } from "../composables/useModelApi";
const {
items: users,
loading,
error,
load: loadUsers,
} = useModelApi(userApi, {
defaultListParams: { ordering: "id" },
loadErrorMessage: "Не удалось загрузить пользователей",
});
onMounted(loadUsers);
</script>
<template>
<section class="hero">
<p class="eyebrow">Directory</p>
<h1>Пользователи</h1>
<p class="subtitle">Вторая страница для проверки маршрутизации и панели навигации.</p>
</section>
<van-notice-bar
v-if="error"
class="notice"
color="#991b1b"
background="#fee2e2"
left-icon="warning-o"
wrapable
:scrollable="false"
:text="error"
/>
<section class="card list-card">
<div class="list-title">
<h2>Список</h2>
<van-button size="small" plain type="primary" :loading="loading" @click="loadUsers()">
Обновить
</van-button>
</div>
<van-loading v-if="loading && users.length === 0" class="state" type="spinner">
Загрузка...
</van-loading>
<van-empty v-else-if="users.length === 0" description="Пользователей пока нет" />
<van-cell-group v-else inset>
<van-cell v-for="user in users" :key="user.id" :title="user.name" center>
<template #label> ID: {{ user.id }} </template>
<template #right-icon>
<van-tag plain type="success">User</van-tag>
</template>
</van-cell>
</van-cell-group>
</section>
</template>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}