master
lion 4 days ago
parent 3c562ae1ef
commit a093799b01

@ -0,0 +1,26 @@
# 在 server { } 内、location / 之前或之后按需 include。
# root 须指向 Laravel 的 public 目录。
# 后台静态资源
location ^~ /admin/assets/ {
try_files $uri =404;
access_log off;
expires 7d;
}
# 后台入口:仅 /admin 与 /admin/ 返回 index.html
location = /admin {
return 302 /admin/;
}
location ^~ /admin/ {
try_files $uri $uri/ @admin_spa;
}
location @admin_spa {
# 深链 /admin/courses 等 → Hash 路由(与 AdminSpaController 一致)
if ($uri ~ "^/admin/(.+)$") {
return 302 /admin/#/$1$is_args$args;
}
try_files /admin/index.html =404;
}

@ -6,7 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build:admin": "vue-tsc -b && vite build --mode admin && cp deploy/admin.htaccess ../slake-school-service/public/admin/.htaccess",
"build:admin": "vue-tsc -b && vite build --mode admin && cp deploy/admin.htaccess ../slake-school-service/public/admin/.htaccess && cp deploy/nginx-admin-spa.conf ../slake-school-service/deploy/nginx-admin-spa.conf",
"preview": "vite preview"
},
"dependencies": {

@ -4,6 +4,8 @@ import type { CourseMediaDto } from '@/api/admin/courses'
export type CourseAssetSubdir = 'covers' | 'promos'
export type EditorImageScope = 'courses' | 'activities' | 'news' | 'banners' | 'common'
export async function uploadCourseAsset(file: File, subdir: CourseAssetSubdir) {
const body = new FormData()
body.append('file', file)
@ -25,3 +27,11 @@ export async function uploadBannerCover(file: File) {
const { data } = await http.post<ApiBody<{ url: string }>>('/admin/v1/uploads/banner-cover', body)
return data.data
}
export async function uploadEditorImage(file: File, scope: EditorImageScope = 'common') {
const body = new FormData()
body.append('file', file)
body.append('scope', scope)
const { data } = await http.post<ApiBody<{ url: string }>>('/admin/v1/uploads/editor-image', body)
return data.data
}

@ -6,6 +6,7 @@ import {
downloadSigninCard,
getSigninCardFilename,
openSigninCardPrintWindow,
renderSigninQrToCanvas,
} from '@/utils/course-signin-qr'
const props = withDefaults(
@ -28,37 +29,63 @@ const QR_SIZE = 200
const qrImageUrl = ref('')
const qrReady = ref(false)
const qrError = ref('')
const qrFallback = ref(false)
const qrImageEl = ref<HTMLImageElement | null>(null)
function revokeQrUrl() {
if (qrImageUrl.value) {
if (qrImageUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(qrImageUrl.value)
qrImageUrl.value = ''
}
qrImageUrl.value = ''
}
async function renderLocalQr() {
const canvas = document.createElement('canvas')
await renderSigninQrToCanvas(canvas, props.signinCode, QR_SIZE)
qrImageUrl.value = canvas.toDataURL('image/png')
qrReady.value = true
qrFallback.value = true
}
async function loadQr() {
revokeQrUrl()
qrReady.value = false
qrError.value = ''
qrFallback.value = false
if (!props.modelValue || !props.signinCode) return
if (props.signinCode.length > 32) {
qrError.value = '签到码超过 32 字符,无法生成微信小程序码'
try {
await renderLocalQr()
} catch {
qrError.value = '签到码超过 32 字符,无法生成二维码'
}
return
}
try {
const blob = await fetchSigninQrImage(props.signinCode)
if (!blob.type.includes('png') && blob.size < 4096) {
qrError.value = await readBlobErrorMessage(blob)
const message = await readBlobErrorMessage(blob)
try {
await renderLocalQr()
qrError.value = `${message},已改用普通二维码`
} catch {
qrError.value = message
}
return
}
qrImageUrl.value = URL.createObjectURL(blob)
qrReady.value = true
} catch (error) {
qrError.value = error instanceof Error ? error.message : '生成小程序码失败'
const message = error instanceof Error ? error.message : '生成小程序码失败'
try {
await renderLocalQr()
qrError.value = `${message},已改用普通二维码`
} catch {
qrError.value = message
}
}
}
@ -112,10 +139,17 @@ function handlePrint() {
alt="签到小程序码"
/>
<div v-else-if="!signinCode" class="qr-empty">暂无签到码</div>
<div v-else-if="qrError" class="qr-empty is-error">{{ qrError }}</div>
<div v-else-if="qrError && !qrReady" class="qr-empty is-error">{{ qrError }}</div>
<div v-else class="qr-empty">小程序码生成中</div>
<p v-if="qrError && qrReady" class="qr-warn">{{ qrError }}</p>
<p v-if="signinCode" class="qr-code-text">{{ signinCode }}</p>
<p class="qr-hint">请使用微信扫一扫将自动打开本小程序签到页</p>
<p class="qr-hint">
{{
qrFallback
? '当前为普通二维码,可在微信小程序内使用「扫一扫」扫描签到码文本'
: '请使用微信扫一扫,将自动打开本小程序签到页'
}}
</p>
</div>
<template #footer>
<el-button @click="closeDialog"></el-button>
@ -153,6 +187,13 @@ function handlePrint() {
.qr-empty.is-error {
color: var(--el-color-danger);
}
.qr-warn {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--el-color-warning);
text-align: center;
}
.qr-code-text {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;

@ -0,0 +1,97 @@
<script setup lang="ts">
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import { ElMessage } from 'element-plus'
import { uploadEditorImage, type EditorImageScope } from '@/api/admin/upload'
const props = withDefaults(
defineProps<{
height?: number
scope?: EditorImageScope
}>(),
{
height: 260,
scope: 'common',
},
)
const content = defineModel<string>({ default: '' })
const toolbar = [
['bold', 'italic', 'underline'],
[{ header: [2, 3, false] }],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'image'],
['clean'],
]
type QuillInstance = {
getSelection: (focus?: boolean) => { index: number; length: number } | null
getLength: () => number
insertEmbed: (index: number, type: string, value: string) => void
setSelection: (index: number, length?: number) => void
getModule: (name: string) => { addHandler: (name: string, handler: () => void) => void }
}
function pickImage(quill: QuillInstance) {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
try {
const res = await uploadEditorImage(file, props.scope)
if (!res.url) {
ElMessage.error('图片上传失败')
return
}
const range = quill.getSelection(true)
const index = range?.index ?? Math.max(0, quill.getLength() - 1)
quill.insertEmbed(index, 'image', res.url)
quill.setSelection(index + 1)
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
'图片上传失败'
ElMessage.error(typeof msg === 'string' ? msg : '图片上传失败')
}
}
input.click()
}
function onEditorReady(quill: QuillInstance) {
quill.getModule('toolbar').addHandler('image', () => pickImage(quill))
}
</script>
<template>
<div class="rich-text-editor" :style="{ height: `${height}px` }">
<QuillEditor
v-model:content="content"
content-type="html"
theme="snow"
:toolbar="toolbar"
@ready="onEditorReady"
/>
</div>
</template>
<style scoped>
.rich-text-editor {
width: 100%;
}
.rich-text-editor :deep(.ql-toolbar),
.rich-text-editor :deep(.ql-container) {
width: 100%;
}
.rich-text-editor :deep(.ql-container) {
height: calc(100% - 42px);
}
.rich-text-editor :deep(.ql-editor img) {
max-width: 100%;
height: auto;
display: block;
margin: 8px 0;
}
</style>

@ -35,7 +35,7 @@ function clickMenu(path: string) {
async function handleCommand(cmd: string) {
if (cmd === 'out') {
await auth.logout()
window.location.assign(`${window.location.origin}/login`)
await router.replace({ name: 'login' })
}
if (cmd === 'pwd') {
pwdForm.value = { password: '', password_confirmation: '' }

@ -1,8 +1,12 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const isAdminDeploy = import.meta.env.MODE === 'admin'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
history: isAdminDeploy
? createWebHashHistory(import.meta.env.BASE_URL)
: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',

@ -1,4 +1,5 @@
import axios from 'axios'
import { isOnLoginPage, loginHref } from '@/utils/login-redirect'
const baseURL = import.meta.env.VITE_API_BASE_URL || '/api'
@ -35,8 +36,8 @@ http.interceptors.response.use(
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('admin_token')
if (!window.location.pathname.startsWith('/login')) {
window.location.assign(`${window.location.origin}/login`)
if (!isOnLoginPage()) {
window.location.assign(loginHref())
}
}
return Promise.reject(err)

@ -0,0 +1,24 @@
const isAdminDeploy = import.meta.env.MODE === 'admin'
function adminBaseUrl(): string {
return `${window.location.origin}${import.meta.env.BASE_URL}`.replace(/\/?$/, '/')
}
export function loginPathname(): string {
return new URL('login', adminBaseUrl()).pathname
}
export function loginHref(): string {
if (isAdminDeploy) {
return `${adminBaseUrl()}#/login`
}
return new URL('login', adminBaseUrl()).href
}
export function isOnLoginPage(): boolean {
if (isAdminDeploy) {
const hash = window.location.hash.replace(/^#/, '')
return hash === '/login' || hash.startsWith('/login?')
}
return window.location.pathname.startsWith(loginPathname())
}

@ -1,6 +1,5 @@
<script setup lang="ts">
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import RichTextEditor from '@/components/RichTextEditor.vue'
import { ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import {
@ -19,14 +18,6 @@ import { enabledStatusClass } from '@/utils/admin-list'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { UploadRequestOptions } from 'element-plus'
const quillToolbar = [
['bold', 'italic', 'underline'],
[{ header: [2, 3, false] }],
[{ list: 'ordered' }, { list: 'bullet' }],
['link'],
['clean'],
]
const typeOptions: { value: BannerType; label: string }[] = [
{ value: 'course', label: '课程' },
{ value: 'activity', label: '活动' },
@ -395,14 +386,7 @@ usePageLoad(load)
</div>
</el-form-item>
<el-form-item label="内容" class="intro-form-item">
<div class="quill-wrap">
<QuillEditor
v-model:content="form.content_html"
content-type="html"
theme="snow"
:toolbar="quillToolbar"
/>
</div>
<RichTextEditor v-model="form.content_html" scope="banners" :height="320" />
</el-form-item>
</template>
</el-form>
@ -432,27 +416,6 @@ usePageLoad(load)
.intro-form-item :deep(.el-form-item__content) {
width: 100%;
}
.quill-wrap {
width: 100%;
height: 320px;
}
.quill-wrap :deep(.ql-toolbar.ql-snow),
.quill-wrap :deep(.ql-container.ql-snow) {
width: 100%;
box-sizing: border-box;
}
.quill-wrap :deep(.ql-container.ql-snow) {
height: calc(320px - 42px);
border: 1px solid var(--el-border-color);
border-top: 0;
}
.quill-wrap :deep(.ql-toolbar.ql-snow) {
border: 1px solid var(--el-border-color);
border-radius: 4px 4px 0 0;
}
.quill-wrap :deep(.ql-editor) {
min-height: 100%;
}
.list-cover-thumb {
width: 56px;
height: 32px;

@ -171,7 +171,7 @@ onBeforeUnmount(destroyMap)
<div v-if="summary" class="radar-top-grid">
<div class="radar-kpi">
<span class="kpi-icon"><el-icon><OfficeBuilding /></el-icon></span>
<span>已覆盖高校<em>有关联老师的高校</em></span>
<span>已覆盖高校<em>已配置经纬度的高校</em></span>
<strong>{{ summary.covered_schools }}</strong>
</div>
<div class="radar-kpi">
@ -194,7 +194,7 @@ onBeforeUnmount(destroyMap)
<section class="radar-map-card">
<div class="radar-map-toolbar">
<h2>高校分布</h2>
<span class="toolbar-hint">以苏州为中心展示点击圆点或学校名称查看老师列表</span>
<span class="toolbar-hint">以苏州为中心展示点击圆点或学校名称查看高校详情</span>
</div>
<div class="radar-main">
<div class="radar-map-container-wrap">
@ -206,7 +206,7 @@ onBeforeUnmount(destroyMap)
<div v-else-if="!mapData?.schools.length && !mapLoading" class="radar-map-placeholder">
<el-icon :size="24"><Location /></el-icon>
<strong>暂无地图点位</strong>
<span>请先在高校坐标库维护经纬度并确保高校下有关联老师</span>
<span>请先在高校坐标库维护经纬度</span>
</div>
<div
v-else
@ -225,6 +225,11 @@ onBeforeUnmount(destroyMap)
</div>
<div class="radar-side-body">
<template v-if="activeSchool">
<div v-if="!activeSchool.teachers.length" class="radar-empty-panel">
<el-icon :size="20"><User /></el-icon>
<strong>暂无关联老师</strong>
<span>该校已在地图上展示可在老师库中关联高校后在此查看老师列表</span>
</div>
<div
v-for="t in activeSchool.teachers"
:key="t.id"
@ -245,7 +250,7 @@ onBeforeUnmount(destroyMap)
<div class="radar-empty-panel">
<el-icon :size="20"><Pointer /></el-icon>
<strong>选择高校点位</strong>
<span>点击地图上的高校圆点右侧会显示该校老师列表研究方向和星级</span>
<span>点击地图上的高校圆点右侧会显示该校信息及关联老师列表</span>
</div>
<div v-if="summary" class="radar-view-summary">
<span>当前视图点位</span>

@ -1,6 +1,5 @@
<script setup lang="ts">
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import RichTextEditor from '@/components/RichTextEditor.vue'
import { ref } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import {
@ -40,14 +39,6 @@ type SessionDraft = {
capacity?: number
}
const quillToolbar = [
['bold', 'italic', 'underline'],
[{ header: [2, 3, false] }],
[{ list: 'ordered' }, { list: 'bullet' }],
['link'],
['clean'],
]
const loading = ref(false)
const saving = ref(false)
const items = ref<ActivityRow[]>([])
@ -694,9 +685,7 @@ usePageLoad(async () => {
</el-col>
<el-col :span="24">
<el-form-item label="活动简介" class="intro-form-item">
<div class="quill-wrap">
<QuillEditor v-model:content="form.intro_html" content-type="html" theme="snow" :toolbar="quillToolbar" />
</div>
<RichTextEditor v-model="form.intro_html" scope="activities" :height="260" />
</el-form-item>
</el-col>
</el-row>
@ -884,17 +873,6 @@ usePageLoad(async () => {
.intro-form-item :deep(.el-form-item__content) {
width: 100%;
}
.quill-wrap {
width: 100%;
height: 260px;
}
.quill-wrap :deep(.ql-toolbar),
.quill-wrap :deep(.ql-container) {
width: 100%;
}
.quill-wrap :deep(.ql-container) {
height: calc(260px - 42px);
}
.dialog-footer-inner {
display: flex;
justify-content: flex-end;

@ -1,7 +1,6 @@
<script setup lang="ts">
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import draggable from 'vuedraggable'
import RichTextEditor from '@/components/RichTextEditor.vue'
import { computed, ref, watch } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import {
@ -67,14 +66,6 @@ const paletteItems: { type: FormFieldType; title: string; defaultLabel: string;
{ type: 'file', title: '多文件上传', defaultLabel: '附件', defaultKey: 'files' },
]
const quillToolbar = [
['bold', 'italic', 'underline'],
[{ header: [2, 3, false] }],
[{ list: 'ordered' }, { list: 'bullet' }],
['link'],
['clean'],
]
let cloneSeq = 0
function clonePaletteItem(p: (typeof paletteItems)[number]): FormField {
@ -1058,9 +1049,7 @@ usePageLoad(async () => {
</el-col>
<el-col :span="24">
<el-form-item label="课程简介" class="intro-form-item">
<div class="quill-wrap">
<QuillEditor v-model:content="form.intro_html" content-type="html" theme="snow" :toolbar="quillToolbar" />
</div>
<RichTextEditor v-model="form.intro_html" scope="courses" :height="260" />
</el-form-item>
</el-col>
</el-row>
@ -1487,17 +1476,6 @@ usePageLoad(async () => {
.intro-form-item :deep(.el-form-item__content) {
width: 100%;
}
.quill-wrap {
width: 100%;
height: 260px;
}
.quill-wrap :deep(.ql-toolbar),
.quill-wrap :deep(.ql-container) {
width: 100%;
}
.quill-wrap :deep(.ql-container) {
height: calc(260px - 42px);
}
.list-thumb-wrap {
display: flex;
justify-content: center;

@ -1,6 +1,5 @@
<script setup lang="ts">
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import RichTextEditor from '@/components/RichTextEditor.vue'
import { ref, watch } from 'vue'
import { usePageLoad } from '@/composables/usePageLoad'
import { useRoute } from 'vue-router'
@ -21,14 +20,6 @@ import type { UploadRequestOptions } from 'element-plus'
type DictOpt = { id: number; label: string; value: string; sort: number }
const quillToolbar = [
['bold', 'italic', 'underline'],
[{ header: [2, 3, false] }],
[{ list: 'ordered' }, { list: 'bullet' }],
['link'],
['clean'],
]
const route = useRoute()
const crawlJobFilter = ref<number | null>(null)
const loading = ref(false)
@ -443,14 +434,7 @@ watch(
</el-col>
<el-col :span="24">
<el-form-item label="资讯正文" required class="intro-form-item">
<div class="quill-wrap">
<QuillEditor
v-model:content="form.content_html"
content-type="html"
theme="snow"
:toolbar="quillToolbar"
/>
</div>
<RichTextEditor v-model="form.content_html" scope="news" :height="320" />
</el-form-item>
</el-col>
</el-row>
@ -501,23 +485,6 @@ watch(
.intro-form-item :deep(.el-form-item__content) {
width: 100%;
}
.quill-wrap {
width: 100%;
height: 320px;
}
.quill-wrap :deep(.ql-toolbar),
.quill-wrap :deep(.ql-container) {
width: 100%;
}
.quill-wrap :deep(.ql-container) {
height: calc(320px - 42px);
}
.quill-wrap :deep(.ql-editor img) {
max-width: 100%;
height: auto;
display: block;
margin: 8px 0;
}
.dialog-footer-inner {
display: flex;
justify-content: flex-end;

Loading…
Cancel
Save