master
lion 3 days ago
parent 64b947db1a
commit c3b48567f0

@ -10,7 +10,7 @@
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>S-lake高校雷达网</title>
<title>S-lake先进技术发展中心</title>
</head>
<body>
<div id="app"></div>

@ -20,6 +20,8 @@ export interface MiniappUserRow {
created_at?: string
admin_user_id?: number | null
admin_user_name?: string | null
identity_type?: 'incubation' | 'partner' | null
identity_label?: string | null
staff_role_label?: string | null
}
@ -61,10 +63,14 @@ export async function fetchMiniappUser(id: number) {
return data.data
}
export async function bindMiniappUserStaff(id: number, adminUserId: number | null) {
export async function bindMiniappUserStaff(
id: number,
adminUserId: number | null,
identityType?: 'incubation' | 'partner' | null,
) {
const { data } = await http.patch<ApiBody<MiniappUserDetail>>(
`/admin/v1/miniapp-users/${id}/bind-staff`,
{ admin_user_id: adminUserId },
{ admin_user_id: adminUserId, identity_type: identityType ?? null },
)
return data.data
}

@ -0,0 +1,37 @@
import { http } from '@/utils/http'
import type { ApiBody, Paginated } from '@/api/types'
export interface PastReviewRow {
id: number
title: string
cover_url?: string | null
sort: number
status: number
created_at?: string | null
updated_at?: string | null
}
export async function fetchPastReviews(params: Record<string, unknown>) {
const { data } = await http.get<ApiBody<Paginated<PastReviewRow>>>('/admin/v1/past-reviews', { params })
return data.data
}
export async function fetchPastReview(id: number) {
const { data } = await http.get<ApiBody<PastReviewRow>>(`/admin/v1/past-reviews/${id}`)
return data.data
}
export async function createPastReview(payload: Record<string, unknown>) {
const { data } = await http.post<ApiBody<{ id: number }>>('/admin/v1/past-reviews', payload)
return data.data
}
export async function updatePastReview(id: number, payload: Record<string, unknown>) {
const { data } = await http.put<ApiBody<PastReviewRow>>(`/admin/v1/past-reviews/${id}`, payload)
return data.data
}
export async function deletePastReview(id: number) {
const { data } = await http.delete<ApiBody<null>>(`/admin/v1/past-reviews/${id}`)
return data
}

@ -58,7 +58,7 @@ async function submitPwd() {
<template>
<el-container class="admin-layout" direction="vertical">
<el-header class="layout-topbar" height="52px">
<div class="brand">S-lake高校雷达网</div>
<div class="brand">S-lake先进技术发展中心</div>
<div class="spacer" />
<el-dropdown class="topbar-user" trigger="click" @command="handleCommand">
<span class="user-link">

@ -19,7 +19,7 @@ const router = createRouter({
router.afterEach((to) => {
const title = typeof to.meta.title === 'string' ? to.meta.title : ''
document.title = title ? `${title} - S-lake高校雷达网` : 'S-lake高校雷达网'
document.title = title ? `${title} - S-lake先进技术发展中心` : 'S-lake先进技术发展中心'
})
router.beforeEach(async (to, _from, next) => {

@ -45,7 +45,7 @@ async function submit() {
<main class="login-main">
<div class="login-container">
<div class="login-hero">
<h1 class="login-hero-title">S-lake先进技术发展中心高校雷达网</h1>
<h1 class="login-hero-title">S-lake先进技术发展中心</h1>
<p class="login-hero-desc">加强长三角高校顶尖科研人才的发现跟踪与服务</p>
</div>

@ -0,0 +1,265 @@
<script setup lang="ts">
import PageTitle from '@/components/PageTitle.vue'
import { ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import {
createPastReview,
deletePastReview,
fetchPastReview,
fetchPastReviews,
updatePastReview,
type PastReviewRow,
} from '@/api/admin/past-reviews'
import { uploadBannerCover } from '@/api/admin/upload'
import { enabledStatusClass } from '@/utils/admin-list'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { UploadRequestOptions } from 'element-plus'
const loading = ref(false)
const saving = ref(false)
const items = ref<PastReviewRow[]>([])
const meta = ref({ current_page: 1, per_page: 20, total: 0 })
const page = ref(1)
const keyword = ref('')
const filterStatus = ref<number | ''>('')
const dialog = ref(false)
const editing = ref<PastReviewRow | null>(null)
const form = ref({
title: '',
cover_url: '',
sort: 0,
status: 1,
})
async function load() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: page.value,
page_size: meta.value.per_page,
}
if (keyword.value) params.keyword = keyword.value
if (filterStatus.value !== '') params.status = filterStatus.value
const res = await fetchPastReviews(params)
items.value = res.items
meta.value = res.meta
} finally {
loading.value = false
}
}
function searchList() {
page.value = 1
void load()
}
function resetFilters() {
keyword.value = ''
filterStatus.value = ''
page.value = 1
void load()
}
function openCreate() {
editing.value = null
form.value = { title: '', cover_url: '', sort: 0, status: 1 }
dialog.value = true
}
async function openEdit(row: PastReviewRow) {
editing.value = row
const detail = await fetchPastReview(row.id)
form.value = {
title: detail.title,
cover_url: detail.cover_url || '',
sort: detail.sort,
status: detail.status,
}
dialog.value = true
}
async function save() {
if (!form.value.title.trim()) {
ElMessage.warning('请填写标题')
return
}
if (!form.value.cover_url) {
ElMessage.warning('请上传封面图')
return
}
const payload = {
title: form.value.title.trim(),
cover_url: form.value.cover_url,
sort: form.value.sort,
status: form.value.status,
}
saving.value = true
try {
if (editing.value) {
await updatePastReview(editing.value.id, payload)
} else {
await createPastReview(payload)
}
ElMessage.success('已保存')
dialog.value = false
await load()
} finally {
saving.value = false
}
}
async function remove(row: PastReviewRow) {
await ElMessageBox.confirm(`确定删除往期回顾「${row.title}」?`, '提示', { type: 'warning' })
await deletePastReview(row.id)
ElMessage.success('已删除')
await load()
}
async function handleCoverUpload(opt: UploadRequestOptions) {
const raw = opt.file
const file = raw instanceof File ? raw : (raw as { raw?: File }).raw
if (!file) {
opt.onError?.(new Error('no file') as never)
return
}
try {
const res = await uploadBannerCover(file)
form.value.cover_url = res.url
ElMessage.success('封面上传成功')
opt.onSuccess?.({} as never)
} catch {
ElMessage.error('封面上传失败')
opt.onError?.(new Error('upload failed') as never)
}
}
usePageLoad(load)
</script>
<template>
<div class="list-page">
<div class="page-header">
<PageTitle />
<el-button type="primary" size="small" class="btn-create" @click="openCreate"></el-button>
</div>
<el-card shadow="never" class="admin-list-card">
<div class="list-filter-bar">
<el-input
v-model="keyword"
placeholder="搜索标题"
clearable
class="filter-search"
@keyup.enter="searchList"
/>
<el-select v-model="filterStatus" clearable placeholder="是否显示" class="filter-select">
<el-option label="显示" :value="1" />
<el-option label="隐藏" :value="0" />
</el-select>
<el-button type="primary" @click="searchList"></el-button>
<el-button @click="resetFilters"></el-button>
</div>
<el-table v-loading="loading" :data="items" row-key="id">
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column label="封面图" width="100" align="center">
<template #default="{ row }">
<el-image
v-if="row.cover_url"
:src="row.cover_url"
:preview-src-list="[row.cover_url]"
fit="cover"
class="list-cover-thumb"
preview-teleported
/>
<span v-else class="text-mute"></span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column label="是否显示" width="100" align="center">
<template #default="{ row }">
<span class="status-badge" :class="enabledStatusClass(row.status)">
{{ row.status === 1 ? '显示' : '隐藏' }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<div class="table-row-actions">
<el-button class="btn-action-primary" @click="openEdit(row)"></el-button>
<el-button class="btn-action-brand" @click="remove(row)"></el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="list-pager">
<el-pagination
v-model:current-page="page"
layout="total, prev, pager, next"
:total="meta.total"
:page-size="meta.per_page"
@current-change="load"
/>
</div>
</el-card>
</div>
<el-dialog
v-model="dialog"
:title="editing ? '编辑往期回顾' : '新增往期回顾'"
width="640px"
destroy-on-close
>
<el-form label-position="top">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="封面图" required>
<div class="cover-upload-row">
<el-upload :show-file-list="false" :http-request="handleCoverUpload" accept="image/*">
<el-button type="primary" plain>上传封面</el-button>
</el-upload>
<el-image
v-if="form.cover_url"
:src="form.cover_url"
fit="cover"
class="list-cover-thumb"
preview-teleported
:preview-src-list="[form.cover_url]"
/>
</div>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="排序" required>
<el-input-number v-model="form.sort" :min="0" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否显示">
<el-radio-group v-model="form.status">
<el-radio :value="1">显示</el-radio>
<el-radio :value="0">隐藏</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="dialog = false">取消</el-button>
<el-button type="primary" class="btn-create" :loading="saving" @click="save"></el-button>
</template>
</el-dialog>
</template>
<style scoped>
.cover-upload-row {
display: flex;
align-items: center;
gap: 12px;
}
</style>

@ -30,6 +30,7 @@ const meta = ref({ current_page: 1, per_page: 20, total: 0 })
const page = ref(1)
const keyword = ref('')
const filterConverted = ref<'' | '0' | '1'>('')
const filterIdentity = ref<'' | 'none' | 'incubation' | 'partner'>('')
const starOptions = ref<DictItemBrief[]>([])
const statusOptions = ref<DictItemBrief[]>([])
@ -43,6 +44,7 @@ const staffOptions = ref<{ id: number; label: string; role: string }[]>([])
const bindVisible = ref(false)
const bindRow = ref<MiniappUserRow | null>(null)
const bindAdminUserId = ref<number | undefined>()
const bindIdentityType = ref<'incubation' | 'partner' | undefined>()
const bindSaving = ref(false)
const convertVisible = ref(false)
@ -75,9 +77,12 @@ async function loadStaffOptions() {
])
const options: { id: number; label: string; role: string }[] = []
for (const item of admins.items) {
const roleLabel = item.roles?.length
? item.roles.map((role) => role.name).join('、')
: '后台账号'
options.push({
id: item.id,
label: `${item.real_name || item.username}(管理员)`,
label: `${item.real_name || item.username}${roleLabel}`,
role: 'admin',
})
}
@ -112,6 +117,7 @@ async function load() {
const params: Record<string, unknown> = { page: page.value, page_size: meta.value.per_page }
if (keyword.value) params.keyword = keyword.value
if (filterConverted.value !== '') params.converted = filterConverted.value
if (filterIdentity.value !== '') params.identity_type = filterIdentity.value
const res = await fetchMiniappUsersList(params)
items.value = res.items
meta.value = res.meta
@ -123,6 +129,7 @@ async function load() {
function resetFilters() {
keyword.value = ''
filterConverted.value = ''
filterIdentity.value = ''
page.value = 1
load()
}
@ -140,6 +147,7 @@ async function openDetail(row: MiniappUserRow) {
function openBind(row: MiniappUserRow) {
bindRow.value = row
bindAdminUserId.value = row.admin_user_id ?? undefined
bindIdentityType.value = row.identity_type ?? undefined
bindVisible.value = true
}
@ -147,8 +155,12 @@ async function saveBind() {
if (!bindRow.value) return
bindSaving.value = true
try {
await bindMiniappUserStaff(bindRow.value.id, bindAdminUserId.value ?? null)
ElMessage.success(bindAdminUserId.value ? '已绑定后台账号' : '已解除绑定')
await bindMiniappUserStaff(
bindRow.value.id,
bindAdminUserId.value ?? null,
bindIdentityType.value ?? null,
)
ElMessage.success('已保存')
bindVisible.value = false
await load()
} finally {
@ -267,6 +279,11 @@ usePageLoad(async () => {
<el-option label="未转入老师库" value="0" />
<el-option label="已转入老师库" value="1" />
</el-select>
<el-select v-model="filterIdentity" placeholder="身份" clearable class="filter-select-wide">
<el-option label="未设置身份" value="none" />
<el-option label="入孵用户" value="incubation" />
<el-option label="合作伙伴" value="partner" />
</el-select>
<el-button type="primary" class="btn-create" @click="searchList"></el-button>
<el-button @click="resetFilters"></el-button>
</div>
@ -290,7 +307,18 @@ usePageLoad(async () => {
{{ formatEnrollments(row.activity_titles) }}
</template>
</el-table-column>
<el-table-column label="身份绑定" width="120" align="center">
<el-table-column label="身份" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.identity_label" type="success" size="small">{{ row.identity_label }}</el-tag>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="绑定账号" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
{{ row.admin_user_name || '—' }}
</template>
</el-table-column>
<el-table-column label="后台角色" width="120" align="center">
<template #default="{ row }">
<el-tag v-if="row.staff_role_label" type="warning" size="small">{{ row.staff_role_label }}</el-tag>
<span v-else class="text-muted"></span>
@ -355,8 +383,14 @@ usePageLoad(async () => {
<el-descriptions-item label="转入老师">
{{ detail.teacher_name ? `${detail.teacher_name}ID ${detail.teacher_id}` : '未转入' }}
</el-descriptions-item>
<el-descriptions-item label="身份绑定">
{{ detail.staff_role_label ? `${detail.staff_role_label}${detail.admin_user_name || '—'}` : '未绑定' }}
<el-descriptions-item label="身份">
{{ detail.identity_label || '—' }}
</el-descriptions-item>
<el-descriptions-item label="绑定账号">
{{ detail.admin_user_name || '—' }}
</el-descriptions-item>
<el-descriptions-item label="后台角色">
{{ detail.staff_role_label || '—' }}
</el-descriptions-item>
</el-descriptions>
@ -387,16 +421,24 @@ usePageLoad(async () => {
</template>
</el-dialog>
<el-dialog v-model="bindVisible" title="绑定管理员/网格员" width="520px" destroy-on-close @closed="bindRow = null">
<el-dialog v-model="bindVisible" title="绑定身份" width="520px" destroy-on-close @closed="bindRow = null">
<div v-if="bindRow" class="follow-teacher-summary">
学员{{ bindRow.name }}
<span v-if="bindRow.mobile"> · {{ bindRow.mobile }}</span>
</div>
<el-form label-position="top" style="margin-top: 12px">
<el-form-item label="绑定后台账号">
<el-select v-model="bindAdminUserId" clearable filterable placeholder="选择管理员或网格员" style="width: 100%">
<el-select v-model="bindAdminUserId" clearable filterable placeholder="选择管理员或网格员(可选)" style="width: 100%">
<el-option v-for="o in staffOptions" :key="o.id" :label="o.label" :value="o.id" />
</el-select>
<div class="bind-hint">绑定后台账号后小程序端按管理员权限访问</div>
</el-form-item>
<el-form-item label="身份">
<el-select v-model="bindIdentityType" clearable placeholder="选择入孵用户或合作伙伴(可选)" style="width: 100%">
<el-option label="入孵用户" value="incubation" />
<el-option label="合作伙伴" value="partner" />
</el-select>
<div class="bind-hint">身份与后台账号独立设置互不影响</div>
</el-form-item>
</el-form>
<template #footer>
@ -539,6 +581,12 @@ usePageLoad(async () => {
color: #e6a23c;
font-size: 13px;
}
.bind-hint {
margin-top: 6px;
color: #909399;
font-size: 12px;
line-height: 1.5;
}
.form-small :deep(.el-form-item__label) {
font-size: 13px;
padding-bottom: 4px;

@ -520,7 +520,7 @@ usePageLoad(async () => {
:class="{ 'is-active': statBucket === 'partner' }"
@click="pickStat('partner')"
>
<div class="talent-stat-label">转化伙伴数量</div>
<div class="talent-stat-label">入孵用户数量</div>
<div class="talent-stat-value is-success">{{ stats.partners }}</div>
</button>
</div>

Loading…
Cancel
Save