Добавил в админку создание фиктивных лекций
Frontend CI / build-and-check (push) Failing after 5m15s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 15s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 18s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 31s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 11s
Frontend CI / build-and-check (push) Failing after 5m15s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 15s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 18s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 31s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 11s
This commit is contained in:
+36
-12
@@ -4,6 +4,7 @@ import type {
|
||||
AuthResponse,
|
||||
CoinTransactionDto,
|
||||
CourseDto,
|
||||
CreateLectureRequest,
|
||||
LectureDto,
|
||||
LectureQuery,
|
||||
LocationDto,
|
||||
@@ -39,10 +40,18 @@ export const lecturesApi = {
|
||||
return extractItems(payload)
|
||||
},
|
||||
get: (id: string | number) => apiRequest<LectureDto>(`/lectures/${id}`),
|
||||
create: (payload: CreateLectureRequest) =>
|
||||
apiRequest<LectureDto>('/lectures', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
enroll: (id: string | number) => apiRequest<void>(`/lectures/${id}/enroll`, { method: 'POST' }),
|
||||
unenroll: (id: string | number) => apiRequest<void>(`/lectures/${id}/enroll`, { method: 'DELETE' }),
|
||||
unenroll: (id: string | number) =>
|
||||
apiRequest<void>(`/lectures/${id}/enroll`, { method: 'DELETE' }),
|
||||
async reviews(id: string | number) {
|
||||
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>(`/lectures/${id}/reviews`)
|
||||
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>(
|
||||
`/lectures/${id}/reviews`,
|
||||
)
|
||||
return extractItems(payload)
|
||||
},
|
||||
}
|
||||
@@ -57,13 +66,15 @@ export const usersApi = {
|
||||
},
|
||||
stats: (id: string | number) => apiRequest<UserStatsDto>(`/users/${id}/stats`),
|
||||
async enrollments(id: string | number) {
|
||||
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(`/users/${id}/enrollments`)
|
||||
const payload = await apiRequest<PagedResult<LectureDto> | LectureDto[] | undefined>(
|
||||
`/users/${id}/enrollments`,
|
||||
)
|
||||
return extractItems(payload)
|
||||
},
|
||||
async achievements(id: string | number) {
|
||||
const payload = await apiRequest<PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[]>(
|
||||
`/users/${id}/achievements`,
|
||||
)
|
||||
const payload = await apiRequest<
|
||||
PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[]
|
||||
>(`/users/${id}/achievements`)
|
||||
if (Array.isArray(payload)) return payload
|
||||
return payload.items ?? []
|
||||
},
|
||||
@@ -74,21 +85,31 @@ export const usersApi = {
|
||||
return extractItems(payload)
|
||||
},
|
||||
setRole: (id: string | number, roles: Array<'Student' | 'Teacher' | 'Admin'>) =>
|
||||
apiRequest<void>(`/users/${id}/role`, { method: 'PATCH', body: JSON.stringify(roles) }),
|
||||
apiRequest<void>(`/users/${id}/role`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(roles),
|
||||
}),
|
||||
setActive: (id: string | number, isActive: boolean) =>
|
||||
apiRequest<void>(`/users/${id}/active`, { method: 'PATCH', body: JSON.stringify(isActive) }),
|
||||
apiRequest<void>(`/users/${id}/active`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(isActive),
|
||||
}),
|
||||
}
|
||||
|
||||
export const achievementsApi = {
|
||||
async list() {
|
||||
const payload = await apiRequest<PagedResult<AchievementDto> | AchievementDto[]>('/achievements')
|
||||
const payload = await apiRequest<PagedResult<AchievementDto> | AchievementDto[]>(
|
||||
'/achievements',
|
||||
)
|
||||
return extractItems(payload)
|
||||
},
|
||||
}
|
||||
|
||||
export const notificationsApi = {
|
||||
async list() {
|
||||
const payload = await apiRequest<PagedResult<UserNotificationDto> | UserNotificationDto[]>('/notifications')
|
||||
const payload = await apiRequest<PagedResult<UserNotificationDto> | UserNotificationDto[]>(
|
||||
'/notifications',
|
||||
)
|
||||
return extractItems(payload)
|
||||
},
|
||||
markAllRead: () => apiRequest<void>('/notifications/read-all', { method: 'PATCH' }),
|
||||
@@ -104,12 +125,15 @@ export const reviewsApi = {
|
||||
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews/pending')
|
||||
return extractItems(payload)
|
||||
},
|
||||
reanalyze: (id: string | number) => apiRequest<void>(`/reviews/${id}/reanalyze`, { method: 'POST' }),
|
||||
reanalyze: (id: string | number) =>
|
||||
apiRequest<void>(`/reviews/${id}/reanalyze`, { method: 'POST' }),
|
||||
}
|
||||
|
||||
export const coursesApi = {
|
||||
async list() {
|
||||
const payload = await apiRequest<PagedResult<CourseDto> | CourseDto[]>('/courses', { query: { PageSize: 100 } })
|
||||
const payload = await apiRequest<PagedResult<CourseDto> | CourseDto[]>('/courses', {
|
||||
query: { PageSize: 100 },
|
||||
})
|
||||
return extractItems(payload)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -83,6 +83,20 @@ export interface LectureDto {
|
||||
isEnrolled?: boolean
|
||||
}
|
||||
|
||||
export interface CreateLectureRequest {
|
||||
courseId: number
|
||||
teacherId?: number | null
|
||||
locationId?: number | null
|
||||
title: string
|
||||
description?: string | null
|
||||
format: ApiLectureFormat
|
||||
startsAt: string
|
||||
endsAt: string
|
||||
isOpen: boolean
|
||||
maxEnrollments: number
|
||||
onlineUrl?: string | null
|
||||
}
|
||||
|
||||
export interface ReviewDto {
|
||||
id: number
|
||||
lectureId: number
|
||||
|
||||
@@ -34,7 +34,22 @@ const syncError = ref('')
|
||||
const syncErrorDetails = ref<string[]>([])
|
||||
const syncStatus = ref<SyncStatusDto | null>(null)
|
||||
const syncResult = ref<SyncResultDto | null>(null)
|
||||
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
|
||||
const creatingDummyLecture = ref(false)
|
||||
const dummyTimeOptions = [
|
||||
{ label: 'Через 3ч 5м', minutes: 185 },
|
||||
{ label: 'Через 1ч 5м', minutes: 65 },
|
||||
{ label: 'Через 5м', minutes: 5 },
|
||||
]
|
||||
const dummyLectureForm = ref({
|
||||
title: 'Фиктивная лекция для проверки уведомлений',
|
||||
offsetMinutes: 185,
|
||||
durationMinutes: 60,
|
||||
maxEnrollments: 100,
|
||||
courseId: null as number | null,
|
||||
})
|
||||
const addToast = inject('addToast') as
|
||||
| ((message: string, type?: 'success' | 'error' | 'info') => void)
|
||||
| undefined
|
||||
const scheduleTypeOptions: Array<{ id: ApiScheduleTypeId; label: string }> = [
|
||||
{ id: 'MID_CHECK', label: 'Аттестация' },
|
||||
{ id: 'CONS', label: 'Консультация' },
|
||||
@@ -80,6 +95,7 @@ const tabConfig: Record<TabKey, TabConfig> = {
|
||||
title: 'Лекции',
|
||||
columns: [
|
||||
{ key: 'title', label: 'Название' },
|
||||
{ key: 'startsAt', label: 'Начало' },
|
||||
{ key: 'teacher', label: 'Преподаватель' },
|
||||
{ key: 'format', label: 'Формат' },
|
||||
{ key: 'status', label: 'Синхронизация', align: 'center' },
|
||||
@@ -120,9 +136,10 @@ const current = computed(() => {
|
||||
if (activeTab.value === 'lectures') {
|
||||
return {
|
||||
...config,
|
||||
rows: lectures.value.map(l => ({
|
||||
rows: lectures.value.map((l) => ({
|
||||
id: l.id,
|
||||
title: l.title || l.courseName || 'Без названия',
|
||||
startsAt: new Date(l.startsAt).toLocaleString('ru-RU'),
|
||||
teacher: l.teacherName || 'Не назначен',
|
||||
format: l.format === 'Online' ? 'Онлайн' : 'Офлайн',
|
||||
status: l.isOpen ? 'Открыта' : 'Закрыта',
|
||||
@@ -132,18 +149,18 @@ const current = computed(() => {
|
||||
if (activeTab.value === 'courses') {
|
||||
return {
|
||||
...config,
|
||||
rows: courses.value.map(c => ({
|
||||
rows: courses.value.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.name || 'Без названия',
|
||||
institute: c.isSynced ? 'Синхронизирован' : 'Ручной',
|
||||
tags: c.tags?.map(tag => `#${tag.name}`).join(' ') || '—',
|
||||
tags: c.tags?.map((tag) => `#${tag.name}`).join(' ') || '—',
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (activeTab.value === 'rooms') {
|
||||
return {
|
||||
...config,
|
||||
rows: locations.value.map(l => ({
|
||||
rows: locations.value.map((l) => ({
|
||||
id: l.id,
|
||||
building: l.building || l.name || '—',
|
||||
room: l.room || '—',
|
||||
@@ -153,7 +170,7 @@ const current = computed(() => {
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
rows: tags.value.map(tag => ({
|
||||
rows: tags.value.map((tag) => ({
|
||||
id: tag.id,
|
||||
tag: `#${tag.name}`,
|
||||
category: tag.type,
|
||||
@@ -164,7 +181,8 @@ const current = computed(() => {
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
const [lecturesResult, coursesResult, locationsResult, tagsResult, syncStatusResult] = await Promise.allSettled([
|
||||
const [lecturesResult, coursesResult, locationsResult, tagsResult, syncStatusResult] =
|
||||
await Promise.allSettled([
|
||||
lecturesApi.list({ PageSize: 100 }),
|
||||
coursesApi.list(),
|
||||
locationsApi.list(),
|
||||
@@ -179,6 +197,62 @@ async function loadData() {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function getDummyLectureCourseId() {
|
||||
const selectedCourseId = dummyLectureForm.value.courseId
|
||||
if (selectedCourseId && courses.value.some((course) => course.id === selectedCourseId))
|
||||
return selectedCourseId
|
||||
return courses.value[0]?.id ?? null
|
||||
}
|
||||
|
||||
async function createDummyLecture() {
|
||||
const courseId = getDummyLectureCourseId()
|
||||
const title = dummyLectureForm.value.title.trim()
|
||||
const durationMinutes = Math.max(5, Number(dummyLectureForm.value.durationMinutes) || 60)
|
||||
const maxEnrollments = Math.max(1, Number(dummyLectureForm.value.maxEnrollments) || 100)
|
||||
|
||||
if (!courseId) {
|
||||
addToast?.('Нужен хотя бы один курс, чтобы создать фиктивную лекцию.', 'error')
|
||||
activeTab.value = 'courses'
|
||||
return
|
||||
}
|
||||
if (!title) {
|
||||
addToast?.('Укажите название фиктивной лекции.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
creatingDummyLecture.value = true
|
||||
try {
|
||||
const location = locations.value[0]
|
||||
const startsAt = new Date(Date.now() + dummyLectureForm.value.offsetMinutes * 60_000)
|
||||
const endsAt = new Date(startsAt.getTime() + durationMinutes * 60_000)
|
||||
const createdLecture = await lecturesApi.create({
|
||||
courseId,
|
||||
teacherId: null,
|
||||
locationId: location?.id ?? null,
|
||||
title,
|
||||
description:
|
||||
'Фиктивная лекция создана из админки для проверки системы отзывов и напоминаний о начале лекции.',
|
||||
format: location ? 'Offline' : 'Online',
|
||||
startsAt: startsAt.toISOString(),
|
||||
endsAt: endsAt.toISOString(),
|
||||
isOpen: true,
|
||||
maxEnrollments,
|
||||
onlineUrl: null,
|
||||
})
|
||||
|
||||
lectures.value = [createdLecture, ...lectures.value]
|
||||
activeTab.value = 'lectures'
|
||||
addToast?.(`Фиктивная лекция создана: ${startsAt.toLocaleString('ru-RU')}.`, 'success')
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Не удалось создать фиктивную лекцию.'
|
||||
const details = extractApiErrorDetails(err)
|
||||
addToast?.(details.length ? `${message}: ${details.join('; ')}` : message, 'error')
|
||||
} finally {
|
||||
creatingDummyLecture.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSyncStatus() {
|
||||
try {
|
||||
syncStatus.value = await syncApi.status()
|
||||
@@ -248,7 +322,12 @@ function extractApiErrorDetails(err: unknown) {
|
||||
const details = err.details
|
||||
if (!details || typeof details !== 'object') return []
|
||||
|
||||
const problem = details as { title?: unknown; detail?: unknown; traceId?: unknown; status?: unknown }
|
||||
const problem = details as {
|
||||
title?: unknown
|
||||
detail?: unknown
|
||||
traceId?: unknown
|
||||
status?: unknown
|
||||
}
|
||||
return [
|
||||
typeof problem.title === 'string' ? `title=${problem.title}` : '',
|
||||
typeof problem.status === 'number' ? `status=${problem.status}` : '',
|
||||
@@ -266,30 +345,118 @@ onMounted(() => {
|
||||
<div class="admin-lectures page-content">
|
||||
<div class="header">
|
||||
<h1 class="page-title">Управление лекциями и справочниками</h1>
|
||||
<button class="btn-secondary" type="button" :disabled="loading" @click="loadData">Обновить</button>
|
||||
<button class="btn-secondary" type="button" :disabled="loading" @click="loadData">
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button :class="{ active: activeTab === 'lectures' }" @click="activeTab = 'lectures'">Лекции</button>
|
||||
<button :class="{ active: activeTab === 'courses' }" @click="activeTab = 'courses'">Курсы</button>
|
||||
<button :class="{ active: activeTab === 'rooms' }" @click="activeTab = 'rooms'">Аудитории</button>
|
||||
<button :class="{ active: activeTab === 'lectures' }" @click="activeTab = 'lectures'">
|
||||
Лекции
|
||||
</button>
|
||||
<button :class="{ active: activeTab === 'courses' }" @click="activeTab = 'courses'">
|
||||
Курсы
|
||||
</button>
|
||||
<button :class="{ active: activeTab === 'rooms' }" @click="activeTab = 'rooms'">
|
||||
Аудитории
|
||||
</button>
|
||||
<button :class="{ active: activeTab === 'tags' }" @click="activeTab = 'tags'">Теги</button>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<GlassCard>
|
||||
<div class="section-title">{{ current.title }}</div>
|
||||
<EmptyState v-if="!current.rows.length && !loading" title="Данных пока нет" subtitle="Backend не вернул записи для выбранного раздела." />
|
||||
<EmptyState
|
||||
v-if="!current.rows.length && !loading"
|
||||
title="Данных пока нет"
|
||||
subtitle="Backend не вернул записи для выбранного раздела."
|
||||
/>
|
||||
<DataTable :columns="current.columns" :rows="current.rows" />
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard>
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<div class="section-title">Фиктивная лекция</div>
|
||||
<div class="sync-meta">Для ручной проверки отзывов и уведомлений</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="form" @submit.prevent="createDummyLecture">
|
||||
<label>Название</label>
|
||||
<input
|
||||
v-model="dummyLectureForm.title"
|
||||
class="glass-input"
|
||||
placeholder="Например, тест уведомлений"
|
||||
/>
|
||||
|
||||
<label>Старт</label>
|
||||
<div class="time-options">
|
||||
<button
|
||||
v-for="option in dummyTimeOptions"
|
||||
:key="option.minutes"
|
||||
class="time-option"
|
||||
:class="{
|
||||
active: dummyLectureForm.offsetMinutes === option.minutes,
|
||||
}"
|
||||
type="button"
|
||||
@click="dummyLectureForm.offsetMinutes = option.minutes"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label>Курс</label>
|
||||
<select v-model="dummyLectureForm.courseId" class="glass-input">
|
||||
<option :value="null">Первый доступный курс</option>
|
||||
<option v-for="course in courses" :key="course.id" :value="course.id">
|
||||
{{ course.name || `Курс #${course.id}` }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div class="dummy-fields">
|
||||
<label>
|
||||
<span>Длительность, мин</span>
|
||||
<input
|
||||
v-model.number="dummyLectureForm.durationMinutes"
|
||||
class="glass-input"
|
||||
min="5"
|
||||
type="number"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Мест</span>
|
||||
<input
|
||||
v-model.number="dummyLectureForm.maxEnrollments"
|
||||
class="glass-input"
|
||||
min="1"
|
||||
type="number"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dummy-note">
|
||||
Лекция создаётся открытой для записи. Аудитория подставится автоматически, если она уже
|
||||
есть в справочнике.
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn-primary" type="submit" :disabled="creatingDummyLecture || loading">
|
||||
{{ creatingDummyLecture ? 'Создаём...' : 'Создать фиктивную лекцию' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard>
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<div class="section-title">Синхронизация расписания</div>
|
||||
<div class="sync-meta">{{ syncMeta }}</div>
|
||||
</div>
|
||||
<span class="sync-status" :class="syncStatus?.status ?? 'idle'">{{ syncStatus?.status ?? 'idle' }}</span>
|
||||
<span class="sync-status" :class="syncStatus?.status ?? 'idle'">{{
|
||||
syncStatus?.status ?? 'idle'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<form class="form" @submit.prevent="runScheduleSync">
|
||||
@@ -298,7 +465,11 @@ onMounted(() => {
|
||||
<label>Период по</label>
|
||||
<input v-model="syncForm.timeMax" class="glass-input" type="datetime-local" />
|
||||
<label>Код специальности</label>
|
||||
<input v-model="syncForm.specialtyCode" class="glass-input" placeholder="Например, 09.03.04" />
|
||||
<input
|
||||
v-model="syncForm.specialtyCode"
|
||||
class="glass-input"
|
||||
placeholder="Например, 09.03.04"
|
||||
/>
|
||||
<label>Типы пар</label>
|
||||
<div class="type-grid">
|
||||
<label v-for="type in scheduleTypeOptions" :key="type.id" class="type-option">
|
||||
@@ -308,9 +479,9 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-if="visibleSyncResult" class="sync-result">
|
||||
Создано: {{ visibleSyncResult.created }},
|
||||
обновлено: {{ visibleSyncResult.updated }},
|
||||
пропущено: {{ visibleSyncResult.skipped }}
|
||||
Создано: {{ visibleSyncResult.created }}, обновлено: {{ visibleSyncResult.updated }},
|
||||
пропущено:
|
||||
{{ visibleSyncResult.skipped }}
|
||||
</div>
|
||||
<div v-if="syncError || visibleSyncResult?.error" class="sync-error">
|
||||
{{ syncError || visibleSyncResult?.error }}
|
||||
@@ -318,7 +489,9 @@ onMounted(() => {
|
||||
<details v-if="visibleSyncDetails.length" class="sync-details">
|
||||
<summary>Подробности ошибки</summary>
|
||||
<ul>
|
||||
<li v-for="detail in visibleSyncDetails" :key="detail">{{ detail }}</li>
|
||||
<li v-for="detail in visibleSyncDetails" :key="detail">
|
||||
{{ detail }}
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
@@ -326,7 +499,12 @@ onMounted(() => {
|
||||
<button class="btn-primary" type="submit" :disabled="syncingSchedule">
|
||||
{{ syncingSchedule ? 'Синхронизируем...' : 'Синхронизировать лекции' }}
|
||||
</button>
|
||||
<button class="btn-secondary" type="button" :disabled="syncingRooms" @click="runRoomsSync">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
type="button"
|
||||
:disabled="syncingRooms"
|
||||
@click="runRoomsSync"
|
||||
>
|
||||
{{ syncingRooms ? 'Синхронизируем...' : 'Синхронизировать аудитории' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -337,17 +515,104 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-lectures { display: flex; flex-direction: column; gap: 16px; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.tabs { display: inline-flex; width: fit-content; border: 1px solid var(--color-border-glass); border-radius: 12px; overflow: hidden; }
|
||||
.tabs button { background: var(--color-white-a70); border: none; padding: 8px 18px; font-size: 13px; cursor: pointer; color: var(--color-text-secondary); }
|
||||
.tabs button.active { background: var(--color-primary-a18); color: var(--color-primary-dark); font-weight: 600; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
||||
.form { display: flex; flex-direction: column; gap: 10px; }
|
||||
.form label { font-size: 12px; font-weight: 600; color: var(--color-text-secondary); }
|
||||
.form-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.type-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; }
|
||||
.admin-lectures {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
border: 1px solid var(--color-border-glass);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tabs button {
|
||||
background: var(--color-white-a70);
|
||||
border: none;
|
||||
padding: 8px 18px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.tabs button.active {
|
||||
background: var(--color-primary-a18);
|
||||
color: var(--color-primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.form label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.time-options {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.time-option {
|
||||
border: 1px solid var(--color-border-glass);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-white-a72);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.time-option.active {
|
||||
background: var(--color-primary-a18);
|
||||
border-color: var(--color-primary-light);
|
||||
color: var(--color-primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.dummy-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.dummy-fields label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.dummy-note {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.type-option {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
@@ -360,11 +625,26 @@ onMounted(() => {
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.type-option input { flex: 0 0 auto; }
|
||||
.type-option span { font-size: 13px; line-height: 1.2; }
|
||||
.sync-meta { font-size: 12px; color: var(--color-text-secondary); margin-top: 4px; }
|
||||
.sync-result { font-size: 13px; color: var(--color-text-secondary); }
|
||||
.sync-error { font-size: 13px; color: var(--color-error); }
|
||||
.type-option input {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.type-option span {
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.sync-meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.sync-result {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.sync-error {
|
||||
font-size: 13px;
|
||||
color: var(--color-error);
|
||||
}
|
||||
.sync-details {
|
||||
border: 1px solid var(--color-error-a24);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -373,9 +653,19 @@ onMounted(() => {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.sync-details summary { cursor: pointer; color: var(--color-error); font-weight: 600; }
|
||||
.sync-details ul { margin: 8px 0 0; padding-left: 18px; overflow-wrap: anywhere; }
|
||||
.sync-details li + li { margin-top: 4px; }
|
||||
.sync-details summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-error);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sync-details ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sync-details li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.sync-status {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--color-border-glass);
|
||||
@@ -385,6 +675,14 @@ onMounted(() => {
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-white-a72);
|
||||
}
|
||||
.sync-status.completed { color: var(--color-success-text); background: var(--color-success-bg-a90); border-color: var(--color-primary-light); }
|
||||
.sync-status.failed { color: var(--color-danger-text); background: var(--color-danger-bg-a90); border-color: var(--color-danger-light); }
|
||||
.sync-status.completed {
|
||||
color: var(--color-success-text);
|
||||
background: var(--color-success-bg-a90);
|
||||
border-color: var(--color-primary-light);
|
||||
}
|
||||
.sync-status.failed {
|
||||
color: var(--color-danger-text);
|
||||
background: var(--color-danger-bg-a90);
|
||||
border-color: var(--color-danger-light);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user