master
lion 1 week ago
parent 655bb65d00
commit d637db70f6

@ -1,7 +1,7 @@
import { http } from '@/utils/http'
import type { ApiBody, Paginated } from '@/api/types'
export type BannerType = 'course' | 'activity' | 'custom'
export type BannerType = 'course' | 'activity' | 'news' | 'custom'
export interface BannerRow {
id: number
@ -11,6 +11,8 @@ export interface BannerRow {
course_title?: string | null
activity_id?: number | null
activity_title?: string | null
news_id?: number | null
news_title?: string | null
title?: string | null
cover_url?: string | null
content_html?: string | null

@ -0,0 +1,24 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const props = withDefaults(
defineProps<{
variant?: 'default' | 'dashboard' | 'radar'
}>(),
{ variant: 'default' },
)
const route = useRoute()
const title = computed(() => String(route.meta.title || ''))
const className = computed(() => {
if (props.variant === 'dashboard') return 'dashboard-page-title'
if (props.variant === 'radar') return 'radar-page-title'
return 'page-title'
})
</script>
<template>
<h1 :class="className">{{ title }}</h1>
</template>

@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
export type RemotePagedItem = { id: number; label: string }
export type RemotePagedFetchParams = {
keyword: string
page: number
pageSize: number
}
export type RemotePagedFetchResult = {
items: RemotePagedItem[]
total: number
}
const props = withDefaults(
defineProps<{
modelValue?: number
placeholder?: string
pageSize?: number
initialOption?: RemotePagedItem | null
fetchPage: (params: RemotePagedFetchParams) => Promise<RemotePagedFetchResult>
}>(),
{
placeholder: '请搜索选择',
pageSize: 20,
initialOption: null,
},
)
const emit = defineEmits<{
'update:modelValue': [value: number | undefined]
}>()
const loading = ref(false)
const options = ref<RemotePagedItem[]>([])
const keyword = ref('')
const page = ref(1)
const total = ref(0)
function mergeInitialOption(items: RemotePagedItem[]) {
const seed = props.initialOption
if (!seed) return items
if (items.some((item) => item.id === seed.id)) return items
return [seed, ...items]
}
async function load(pageNum = 1, kw = keyword.value) {
loading.value = true
try {
const res = await props.fetchPage({ keyword: kw, page: pageNum, pageSize: props.pageSize })
options.value = mergeInitialOption(res.items)
total.value = res.total
page.value = pageNum
keyword.value = kw
} finally {
loading.value = false
}
}
function onRemoteMethod(kw: string) {
void load(1, kw)
}
function onPageChange(nextPage: number) {
void load(nextPage, keyword.value)
}
function onVisibleChange(visible: boolean) {
if (visible) {
void load(1, keyword.value)
}
}
watch(
() => props.initialOption,
() => {
options.value = mergeInitialOption(options.value)
},
)
defineExpose({ reload: () => load(1, keyword.value) })
</script>
<template>
<el-select
:model-value="modelValue"
filterable
remote
reserve-keyword
:placeholder="placeholder"
:remote-method="onRemoteMethod"
:loading="loading"
style="width: 100%"
@update:model-value="emit('update:modelValue', $event)"
@visible-change="onVisibleChange"
>
<el-option v-for="item in options" :key="item.id" :label="item.label" :value="item.id" />
<template v-if="total > pageSize" #footer>
<div class="remote-paged-select-footer" @click.stop @mousedown.stop>
<el-pagination
small
layout="total, prev, pager, next"
:total="total"
:page-size="pageSize"
:current-page="page"
@current-change="onPageChange"
/>
</div>
</template>
</el-select>
</template>
<style scoped>
.remote-paged-select-footer {
display: flex;
justify-content: center;
padding: 6px 8px 4px;
border-top: 1px solid var(--el-border-color-lighter);
}
</style>

@ -17,6 +17,11 @@ const router = createRouter({
],
})
router.afterEach((to) => {
const title = typeof to.meta.title === 'string' ? to.meta.title : ''
document.title = title ? `${title} - S-lake高校雷达网` : 'S-lake高校雷达网'
})
router.beforeEach(async (to, _from, next) => {
const auth = useAuthStore()

@ -1,4 +1,9 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import RemotePagedSelect, {
type RemotePagedFetchParams,
type RemotePagedItem,
} from '@/components/RemotePagedSelect.vue'
import RichTextEditor from '@/components/RichTextEditor.vue'
import { ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
@ -13,6 +18,7 @@ import {
} from '@/api/admin/banners'
import { fetchActivities } from '@/api/admin/activities'
import { fetchCourses } from '@/api/admin/courses'
import { fetchNewsList } from '@/api/admin/news'
import { uploadBannerCover } from '@/api/admin/upload'
import { enabledStatusClass } from '@/utils/admin-list'
import { ElMessage, ElMessageBox } from 'element-plus'
@ -21,6 +27,7 @@ import type { UploadRequestOptions } from 'element-plus'
const typeOptions: { value: BannerType; label: string }[] = [
{ value: 'course', label: '课程' },
{ value: 'activity', label: '活动' },
{ value: 'news', label: '资讯' },
{ value: 'custom', label: '自定义' },
]
@ -39,6 +46,7 @@ const form = ref({
type: 'course' as BannerType,
course_id: undefined as number | undefined,
activity_id: undefined as number | undefined,
news_id: undefined as number | undefined,
title: '',
cover_url: '',
content_html: '',
@ -46,10 +54,12 @@ const form = ref({
status: 1,
})
const courseOptions = ref<{ id: number; title: string }[]>([])
const activityOptions = ref<{ id: number; title: string }[]>([])
const courseLoading = ref(false)
const activityLoading = ref(false)
const PICK_PAGE_SIZE = 20
const courseSeed = ref<RemotePagedItem | null>(null)
const activitySeed = ref<RemotePagedItem | null>(null)
const newsSeed = ref<RemotePagedItem | null>(null)
const pickSelectKey = ref(0)
async function load() {
loading.value = true
@ -82,34 +92,58 @@ function resetFilters() {
void load()
}
async function searchCourses(keyword = '') {
courseLoading.value = true
try {
const res = await fetchCourses({ keyword, page: 1, page_size: 30 })
courseOptions.value = res.items.map((c) => ({ id: c.id, title: c.title }))
} finally {
courseLoading.value = false
async function fetchCoursePage({ keyword, page, pageSize }: RemotePagedFetchParams) {
const res = await fetchCourses({
keyword: keyword || undefined,
page,
page_size: pageSize,
})
return {
items: res.items.map((c) => ({ id: c.id, label: c.title })),
total: res.meta.total,
}
}
async function searchActivities(keyword = '') {
activityLoading.value = true
try {
const res = await fetchActivities({ keyword, page: 1, page_size: 30 })
activityOptions.value = res.items.map((a) => ({ id: a.id, title: a.title }))
} finally {
activityLoading.value = false
async function fetchActivityPage({ keyword, page, pageSize }: RemotePagedFetchParams) {
const res = await fetchActivities({
keyword: keyword || undefined,
page,
page_size: pageSize,
})
return {
items: res.items.map((a) => ({ id: a.id, label: a.title })),
total: res.meta.total,
}
}
function onTypeChange(type: BannerType) {
async function fetchNewsPage({ keyword, page, pageSize }: RemotePagedFetchParams) {
const res = await fetchNewsList({
keyword: keyword || undefined,
page,
page_size: pageSize,
status: 1,
})
return {
items: res.items.map((n) => ({ id: n.id, label: n.title })),
total: res.meta.total,
}
}
function resetPickSeeds() {
courseSeed.value = null
activitySeed.value = null
newsSeed.value = null
pickSelectKey.value += 1
}
function onTypeChange() {
form.value.course_id = undefined
form.value.activity_id = undefined
form.value.news_id = undefined
form.value.title = ''
form.value.cover_url = ''
form.value.content_html = ''
if (type === 'course') void searchCourses()
if (type === 'activity') void searchActivities()
resetPickSeeds()
}
function openCreate() {
@ -118,16 +152,15 @@ function openCreate() {
type: 'course',
course_id: undefined,
activity_id: undefined,
news_id: undefined,
title: '',
cover_url: '',
content_html: '',
sort: 0,
status: 1,
}
courseOptions.value = []
activityOptions.value = []
resetPickSeeds()
dialog.value = true
void searchCourses()
}
async function openEdit(row: BannerRow) {
@ -137,21 +170,27 @@ async function openEdit(row: BannerRow) {
type: detail.type,
course_id: detail.course_id ?? undefined,
activity_id: detail.activity_id ?? undefined,
news_id: detail.news_id ?? undefined,
title: detail.title || '',
cover_url: detail.cover_url || '',
content_html: detail.content_html || '',
sort: detail.sort,
status: detail.status,
}
if (detail.type === 'course' && detail.course_id) {
courseOptions.value = [{ id: detail.course_id, title: detail.course_title || `#${detail.course_id}` }]
}
if (detail.type === 'activity' && detail.activity_id) {
activityOptions.value = [{ id: detail.activity_id, title: detail.activity_title || `#${detail.activity_id}` }]
}
courseSeed.value =
detail.type === 'course' && detail.course_id
? { id: detail.course_id, label: detail.course_title || `#${detail.course_id}` }
: null
activitySeed.value =
detail.type === 'activity' && detail.activity_id
? { id: detail.activity_id, label: detail.activity_title || `#${detail.activity_id}` }
: null
newsSeed.value =
detail.type === 'news' && detail.news_id
? { id: detail.news_id, label: detail.news_title || `#${detail.news_id}` }
: null
pickSelectKey.value += 1
dialog.value = true
if (detail.type === 'course') void searchCourses()
if (detail.type === 'activity') void searchActivities()
}
function validateForm(): boolean {
@ -163,6 +202,10 @@ function validateForm(): boolean {
ElMessage.warning('请选择活动')
return false
}
if (form.value.type === 'news' && !form.value.news_id) {
ElMessage.warning('请选择资讯')
return false
}
if (form.value.type === 'custom') {
if (!form.value.title.trim()) {
ElMessage.warning('请填写标题')
@ -185,6 +228,7 @@ async function save() {
status: form.value.status,
course_id: form.value.type === 'course' ? form.value.course_id : null,
activity_id: form.value.type === 'activity' ? form.value.activity_id : null,
news_id: form.value.type === 'news' ? form.value.news_id : null,
title: form.value.type === 'custom' ? form.value.title.trim() : null,
cover_url: form.value.type === 'custom' ? form.value.cover_url : null,
content_html:
@ -241,7 +285,7 @@ usePageLoad(load)
<template>
<div class="list-page">
<div class="page-header">
<h1 class="page-title">Banner图管理</h1>
<PageTitle />
<el-button type="primary" size="small" class="btn-create" @click="openCreate"> Banner</el-button>
</div>
@ -249,7 +293,7 @@ usePageLoad(load)
<div class="list-filter-bar">
<el-input
v-model="keyword"
placeholder="搜索标题、课程、活动…"
placeholder="搜索标题、课程、活动、资讯…"
clearable
class="filter-search"
@keyup.enter="searchList"
@ -341,33 +385,36 @@ usePageLoad(load)
</el-row>
<el-form-item v-if="form.type === 'course'" label="选择课程" required>
<el-select
<RemotePagedSelect
:key="`course-${pickSelectKey}`"
v-model="form.course_id"
filterable
remote
reserve-keyword
placeholder="搜索课程名称或编号"
:remote-method="searchCourses"
:loading="courseLoading"
style="width: 100%"
>
<el-option v-for="c in courseOptions" :key="c.id" :label="c.title" :value="c.id" />
</el-select>
:page-size="PICK_PAGE_SIZE"
:initial-option="courseSeed"
:fetch-page="fetchCoursePage"
/>
</el-form-item>
<el-form-item v-if="form.type === 'activity'" label="选择活动" required>
<el-select
<RemotePagedSelect
:key="`activity-${pickSelectKey}`"
v-model="form.activity_id"
filterable
remote
reserve-keyword
placeholder="搜索活动名称"
:remote-method="searchActivities"
:loading="activityLoading"
style="width: 100%"
>
<el-option v-for="a in activityOptions" :key="a.id" :label="a.title" :value="a.id" />
</el-select>
:page-size="PICK_PAGE_SIZE"
:initial-option="activitySeed"
:fetch-page="fetchActivityPage"
/>
</el-form-item>
<el-form-item v-if="form.type === 'news'" label="选择资讯" required>
<RemotePagedSelect
:key="`news-${pickSelectKey}`"
v-model="form.news_id"
placeholder="搜索已发布资讯标题"
:page-size="PICK_PAGE_SIZE"
:initial-option="newsSeed"
:fetch-page="fetchNewsPage"
/>
</el-form-item>
<template v-if="form.type === 'custom'">

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { computed, ref, watch } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import { useRouter } from 'vue-router'
@ -522,7 +523,7 @@ usePageLoad(resetPage)
<template>
<div class="list-page">
<div class="page-header">
<h1 class="page-title">数据采集</h1>
<PageTitle />
</div>
<el-card shadow="never" class="admin-list-card">

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import {
@ -172,7 +173,7 @@ onBeforeUnmount(destroyMap)
<template>
<div v-loading="loading" class="dashboard-page executive-dashboard">
<div class="page-head">
<h1 class="radar-page-title">高校雷达网地图</h1>
<PageTitle variant="radar" />
</div>
<div v-if="summary" class="radar-top-grid">

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { ref, watch } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import { useRoute } from 'vue-router'
@ -119,7 +120,7 @@ watch(
<template>
<div class="list-page">
<div class="page-header">
<h1 class="page-title">论文库</h1>
<PageTitle />
</div>
<el-card shadow="never" class="admin-list-card">

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { nextTick, ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import {
@ -153,7 +154,7 @@ usePageLoad(load)
<template>
<div class="list-page">
<div class="page-header">
<h1 class="page-title">高校坐标库</h1>
<PageTitle />
<el-button type="primary" size="small" class="btn-create" @click="openCreate"></el-button>
</div>

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { computed, ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import { useRouter } from 'vue-router'
@ -62,7 +63,7 @@ usePageLoad(load)
<template>
<div v-loading="loading" class="dashboard-page executive-dashboard dashboard-v2">
<div class="page-head">
<h1 class="dashboard-page-title">驾驶舱</h1>
<PageTitle variant="dashboard" />
</div>
<template v-if="data">

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import { fetchDictByCode } from '@/api/admin/dict'
@ -160,7 +161,7 @@ usePageLoad(async () => {
<template>
<div class="list-page demands-page">
<div class="page-header">
<h1 class="page-title">需求管理</h1>
<PageTitle />
</div>
<el-card shadow="never" class="admin-list-card">

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import RichTextEditor from '@/components/RichTextEditor.vue'
import { ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
@ -504,7 +505,7 @@ usePageLoad(async () => {
<template>
<div class="list-page">
<div class="page-header">
<h1 class="page-title">活动管理</h1>
<PageTitle />
<el-button type="primary" size="small" class="btn-create" @click="openCreate"></el-button>
</div>

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import draggable from 'vuedraggable'
import RichTextEditor from '@/components/RichTextEditor.vue'
import { computed, ref, watch } from 'vue'
@ -768,7 +769,7 @@ usePageLoad(async () => {
<template>
<div class="list-page">
<div class="page-header">
<h1 class="page-title">课程管理</h1>
<PageTitle />
<el-button type="primary" size="small" class="btn-create" @click="openCreate"></el-button>
</div>

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import RichTextEditor from '@/components/RichTextEditor.vue'
import { ref, watch } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
@ -258,7 +259,7 @@ watch(
<template>
<div class="list-page">
<div class="page-header">
<h1 class="page-title">资讯管理</h1>
<PageTitle />
<el-button type="primary" size="small" class="btn-create" @click="openCreate"></el-button>
</div>

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { computed, ref, watch } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import { fetchDictByCode } from '@/api/admin/dict'
@ -197,7 +198,7 @@ usePageLoad(async () => {
<template>
<div class="list-page students-page">
<div class="page-header">
<h1 class="page-title">学员库</h1>
<PageTitle />
</div>
<el-card shadow="never" class="admin-list-card">

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import { fetchOperationLogs } from '@/api/admin/logs'
@ -48,7 +49,7 @@ usePageLoad(load)
<template>
<div class="list-page">
<div class="page-header">
<h1 class="page-title">操作日志</h1>
<PageTitle />
</div>
<el-card shadow="never" class="admin-list-card">

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import {
@ -163,7 +164,7 @@ usePageLoad(loadTypes)
<template>
<div class="list-page">
<div class="page-header">
<h1 class="page-title">字典管理</h1>
<PageTitle />
<el-button type="primary" size="small" class="btn-create" @click="openCreateType"></el-button>
</div>

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import {
@ -157,7 +158,7 @@ usePageLoad(async () => {
<template>
<div class="list-page">
<div class="page-header">
<h1 class="page-title">网格员管理</h1>
<PageTitle />
<el-button type="primary" size="small" class="btn-create" @click="openCreate"></el-button>
</div>

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import { fetchMenuTree, createMenu, updateMenu, deleteMenu } from '@/api/admin/menus'
@ -122,7 +123,7 @@ usePageLoad(load)
<template>
<div class="list-page">
<div class="page-header">
<h1 class="page-title">菜单管理</h1>
<PageTitle />
<div class="page-header-actions">
<el-button @click="load"></el-button>
<el-button type="primary" size="small" class="btn-create" @click="openCreate(null)"></el-button>

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import {
@ -103,7 +104,7 @@ usePageLoad(load)
<template>
<div class="list-page">
<div class="page-header">
<h1 class="page-title">研究方向</h1>
<PageTitle />
<el-button type="primary" size="small" class="btn-create" @click="openCreate"></el-button>
</div>

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { nextTick, ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import { fetchRoles, fetchRole, createRole, updateRole, deleteRole } from '@/api/admin/roles'
@ -138,7 +139,7 @@ usePageLoad(load)
<template>
<div class="list-page">
<div class="page-header">
<h1 class="page-title">角色管理</h1>
<PageTitle />
<el-button type="primary" size="small" class="btn-create" @click="openCreate"></el-button>
</div>

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import {
@ -140,7 +141,7 @@ usePageLoad(async () => {
<template>
<div class="list-page">
<div class="page-header">
<h1 class="page-title">管理员</h1>
<PageTitle />
<el-button type="primary" size="small" class="btn-create" @click="openCreate"></el-button>
</div>

@ -1,4 +1,5 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { computed, ref, watch } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import { fetchDictByCode } from '@/api/admin/dict'
@ -448,7 +449,7 @@ usePageLoad(async () => {
<template>
<div class="list-page teachers-page">
<div class="page-header">
<h1 class="page-title">老师库</h1>
<PageTitle />
<div class="page-header-actions">
<el-button class="btn-action-secondary" size="small" @click="openBatch"></el-button>
<el-button

Loading…
Cancel
Save