refactor(pc): 从 Gi Admin Pro + Arco 重搭为纯 Vue 3 + Naive UI
旧模板的 193 个文件整体删除,换成纯 Vue 3.5 + Naive UI 2.42 的树。
- 去掉 auto-import(组件与 API 全显式 import)、v-hasPerm(改 user.has('码'),全库 8 处)、
Stylelint;n-tag 的 type 统一过 utils/tag.ts 转换(Arco 色词直接透传会静默变灰)
- 图标改为构建期抽取:scripts/extract-pc-icons.py -> icons/icon-data.ts(37 个),
内网离线部署不会白屏
- 重搭后修掉 6 类"编译 / lint / typecheck 全绿、界面上就是坏的"故障:
① NGrid 的 cols 写成像素值(不是断点名 -> 永远只 1 列,工作台因此错乱)
② n-tab-pane 用 tab 属性(只渲染空注释节点,整排 tab 一个字都不显示)
③ 漏 import 被当未知自定义元素(样式整块丢) ④ .n-card__content 类名不存在
(高度链第一环失效,表格行在 DOM 里但屏幕上没有) ⑤ 填报单状态映射错位一格
⑥ 有标题的卡片没有上内边距(naive 的 padding-top 只给"内容区是长子"的卡片)
- 卡片式版面提成全局基线:styles/index.scss 的 xt-* 令牌 + .page-card / .toolbar /
.section-title,页面只引用、不再各写一套
- 品牌:改名「深学在线」,文字标换成几何标(方 = 卷宗 / 环 = 在线,不用文字字形);
index.html 的页签图标从 404 的 /favicon.ico 改指真实存在的 /favicon.svg
This commit is contained in:
@@ -1,98 +1,17 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false">
|
||||
<template #title>通知记录</template>
|
||||
<template #extra>
|
||||
<span class="notify__hint">
|
||||
「已发出」= 站内信已写进他的消息中心;微信推送要看学生有没有授权
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
⚠️ 这一行是刻意留的:一期的推送渠道只有微信订阅消息(一次性授权),
|
||||
拿不到授权时消息**只落站内信**。不写清这一点,
|
||||
运营会把「额度不足跳过」当成系统故障去查通道。
|
||||
-->
|
||||
<a-alert v-if="rate" type="normal" class="notify__rate">
|
||||
<template #title>
|
||||
送达率 <b>{{ rate }}</b>
|
||||
—— 分母含「额度不足跳过」。
|
||||
跳过**不是失败**:内容已经进了学生的消息中心,只是没有推送。
|
||||
要提升这个数字,该做的是引导学生授权,而不是查通道。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<div class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<a-select v-model="query.taskId" placeholder="全部任务" allow-clear size="small"
|
||||
class="notify__task" :loading="taskLoading" @change="search">
|
||||
<a-option v-for="t in taskOptions" :key="t.id" :value="t.id">{{ t.taskName }}</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="query.bizType" placeholder="全部类型" allow-clear size="small"
|
||||
class="notify__biz" @change="search">
|
||||
<a-option v-for="b in BIZ_TYPES" :key="b.value" :value="b.value">{{ b.label }}</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="query.status" placeholder="全部状态" allow-clear size="small"
|
||||
class="notify__status" @change="search">
|
||||
<a-option v-for="(v, k) in NOTIFY_STATUS" :key="k" :value="Number(k)">{{ v.text }}</a-option>
|
||||
</a-select>
|
||||
<a-button size="small" @click="load">
|
||||
<template #icon><IconRefresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
|
||||
:scroll="{ x: 1100 }" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="时间" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<span class="notify__muted">{{ fmt(record.sendTime) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="收件人" :width="140">
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.receiverName || '—' }}</div>
|
||||
<div class="notify__muted">ID {{ record.receiverId }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="类型" :width="110">
|
||||
<template #cell="{ record }">
|
||||
<a-tag size="small">{{ bizTypeText(record.bizType) }}</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="内容" :width="300">
|
||||
<template #cell="{ record }">
|
||||
<div class="notify__content">{{ record.content || '—' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="渠道" :width="130">
|
||||
<template #cell="{ record }">
|
||||
<span class="notify__muted">{{ channelText(record.channel) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<a-tag size="small" :color="notifyStatus(record.status).color">
|
||||
{{ record.statusText || notifyStatus(record.status).text }}
|
||||
</a-tag>
|
||||
<!-- 失败原因要露出来:不然「发送失败」这四个字没法排查 -->
|
||||
<div v-if="record.failReason" class="notify__fail" :title="record.failReason">
|
||||
{{ record.failReason }}
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { NotifyRecord } from '@/apis/xt/notify'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NDataTable,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NTag
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
BIZ_TYPES,
|
||||
bizTypeText,
|
||||
@@ -102,21 +21,23 @@ import {
|
||||
notifyStatus
|
||||
} from '@/apis/xt/notify'
|
||||
import { getTaskList } from '@/apis/xt/task'
|
||||
import { listPagination } from '@/composables/pagination'
|
||||
import AppIcon from '@/icons/AppIcon.vue'
|
||||
import { tagType } from '@/utils/tag'
|
||||
|
||||
/**
|
||||
* 通知记录(《03-接口设计》§13.3)。
|
||||
*
|
||||
* ## 这一屏存在的意义:把「通知到底发出去没有」变成可回答的
|
||||
*
|
||||
* 在此之前,驳回与审批只能回 `notifySent=false` 并让老师口头告知。
|
||||
* 现在每次通知都会写一条发送记录,所以运营能看到:
|
||||
* · **已送达**:站内信写进去了;
|
||||
* · **额度不足跳过**(3):内容进了站内信,但没推送 —— **这不是故障**,
|
||||
* 每次通知都会写一条发送记录,所以运营能看到三种结果:
|
||||
* · **已送达**(1):站内信写进去了;
|
||||
* · **额度不足跳过**(3):内容进了站内信,但**没有推送** —— 这**不是故障**,
|
||||
* 该做的是引导学生授权;
|
||||
* · **发送失败**(2):真的出了问题(微信接口异常等),要查通道。
|
||||
*
|
||||
* 把 2 和 3 混在一起显示成"失败",会让运营一直去查通道故障,
|
||||
* 而真正的原因(学生没授权)永远没人管。
|
||||
* 而真正的原因(学生没授权)永远没人管 —— 所以标签颜色与文案都分开。
|
||||
*/
|
||||
defineOptions({ name: 'NotifyRecords' })
|
||||
|
||||
@@ -125,7 +46,24 @@ const taskLoading = ref(false)
|
||||
const list = ref<NotifyRecord[]>([])
|
||||
const total = ref(0)
|
||||
const rate = ref('')
|
||||
const taskOptions = ref<Array<{ id: number, taskName: string }>>([])
|
||||
const taskOptions = ref<Array<{ label: string, value: number }>>([])
|
||||
|
||||
/**
|
||||
* `n-card` 的内容区要变成"能撑满的 flex 列"(列表页高度链的中间一环)。
|
||||
*
|
||||
* ⚠️ 曾经用 `:deep(.n-card__content)` 写这条,**但那是个不存在的类名**:
|
||||
* naive-ui 2.45.3 的内容区是 `.n-card-content`(`Card.mjs` 里 `cB("card-content")`)。
|
||||
* 匹配不到 → 内容区还是 `display: block` → `.page-card__body` 的 `flex: 1` 无处可长
|
||||
* → 表格只有 81px 高、分页器浮在卡片中间(不报错,只是"看起来没贴底")。
|
||||
* 所以这里用 `content-style` 直接写在元素上,不依赖类名。
|
||||
*/
|
||||
const contentStyle: CSSProperties = {
|
||||
flex: '1 1 auto',
|
||||
minHeight: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}
|
||||
|
||||
const query = reactive<{
|
||||
taskId?: number
|
||||
@@ -141,31 +79,98 @@ const query = reactive<{
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
/**
|
||||
* 送达率的分母含「额度不足跳过」。
|
||||
*
|
||||
* ⚠️ 这句解释必须留在界面上:不写清,运营看到 80% 就会去查通道,
|
||||
* 而实际该做的是引导学生授权(一次性订阅消息,发几次耗几次)。
|
||||
*/
|
||||
const rateText = computed(() => rate.value || '—')
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
Object.keys(NOTIFY_STATUS)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map((k) => ({ label: NOTIFY_STATUS[k].text, value: k }))
|
||||
)
|
||||
|
||||
/** `remote` 模式:每页条数改了必须重新取数,否则"分页器说 50、表里 20" */
|
||||
const pagination = computed(() => listPagination({
|
||||
page: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
itemCount: total.value,
|
||||
pageSizes: [20, 50, 100]
|
||||
}))
|
||||
|
||||
function fmt(v?: string) {
|
||||
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<NotifyRecord>>(() => [
|
||||
{
|
||||
title: '时间',
|
||||
key: 'sendTime',
|
||||
width: 150,
|
||||
render: (row) => h('span', { class: 'notify__muted' }, fmt(row.sendTime))
|
||||
},
|
||||
{
|
||||
title: '收件人',
|
||||
key: 'receiverName',
|
||||
width: 140,
|
||||
render: (row) => h('div', [
|
||||
h('div', row.receiverName || '—'),
|
||||
h('div', { class: 'notify__muted' }, `ID ${row.receiverId ?? '—'}`)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
key: 'bizType',
|
||||
width: 110,
|
||||
render: (row) => h(NTag, { size: 'small', bordered: false }, { default: () => bizTypeText(row.bizType) })
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
key: 'content',
|
||||
width: 300,
|
||||
// 关键词只用于本页过滤(接口没有内容搜索参数),所以不做服务端查询
|
||||
render: (row) => h('div', { class: 'notify__content' }, row.content || '—')
|
||||
},
|
||||
{
|
||||
title: '渠道',
|
||||
key: 'channel',
|
||||
width: 130,
|
||||
render: (row) => h('span', { class: 'notify__muted' }, channelText(row.channel))
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 170,
|
||||
render: (row) => {
|
||||
const meta = notifyStatus(row.status)
|
||||
return h('div', [
|
||||
h(NTag, { size: 'small', bordered: false, type: tagType(meta.color) }, { default: () => row.statusText || meta.text }),
|
||||
// 失败原因要露出来:不然「发送失败」这四个字没法排查
|
||||
row.failReason
|
||||
? h('div', { class: 'notify__fail', title: row.failReason }, row.failReason)
|
||||
: null
|
||||
])
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getNotifyRecords({
|
||||
const page = await getNotifyRecords({
|
||||
taskId: query.taskId,
|
||||
bizType: query.bizType,
|
||||
status: query.status,
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
})
|
||||
list.value = res.data?.list ?? []
|
||||
total.value = res.data?.total ?? 0
|
||||
rate.value = res.data?.deliveryRate ?? ''
|
||||
list.value = page?.list ?? []
|
||||
total.value = page?.total ?? 0
|
||||
rate.value = page?.deliveryRate ?? ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -174,8 +179,8 @@ async function load() {
|
||||
async function loadTasks() {
|
||||
taskLoading.value = true
|
||||
try {
|
||||
const res = await getTaskList({ pageNum: 1, pageSize: 200 })
|
||||
taskOptions.value = (res.data?.list ?? []).map((t) => ({ id: t.id, taskName: t.taskName }))
|
||||
const page = await getTaskList({ pageNum: 1, pageSize: 200 })
|
||||
taskOptions.value = (page?.list ?? []).map((t) => ({ label: t.taskName, value: t.id }))
|
||||
} finally {
|
||||
taskLoading.value = false
|
||||
}
|
||||
@@ -191,54 +196,144 @@ function onPageChange(page: number) {
|
||||
load()
|
||||
}
|
||||
|
||||
load()
|
||||
loadTasks()
|
||||
function onPageSizeChange(size: number) {
|
||||
query.pageSize = size
|
||||
query.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notify {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
<template>
|
||||
<NCard class="page-card notify" :bordered="false" title="通知记录" :content-style="contentStyle">
|
||||
<template #header-extra>
|
||||
<span class="toolbar__hint">
|
||||
「已送达」= 站内信已写进他的消息中心;微信推送要看学生有没有授权
|
||||
</span>
|
||||
</template>
|
||||
|
||||
&__rate {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
<!--
|
||||
⚠️ 这一段是刻意留的:推送渠道只有微信订阅消息(一次性授权),
|
||||
拿不到授权时消息**只落站内信**。不写清这一点,
|
||||
运营会把「额度不足跳过」当成系统故障去查通道。
|
||||
-->
|
||||
<NAlert type="info" class="notify__rate">
|
||||
<template #header>送达率 {{ rateText }}</template>
|
||||
分母含「额度不足跳过」。跳过<strong>不是失败</strong>:内容已经进了学生的消息中心,
|
||||
只是没有推送。要提升这个数字,该做的是引导学生授权,而不是查通道。
|
||||
</NAlert>
|
||||
|
||||
&__task {
|
||||
width: 200px;
|
||||
}
|
||||
<!--
|
||||
工具条:筛选控件**统一默认尺寸**(同一行不要大小混用)。
|
||||
之前全是 `size="small"`,28px 的白底控件贴在白底表格上几乎看不出边界。
|
||||
-->
|
||||
<div class="toolbar">
|
||||
<NSpace align="center" :size="8">
|
||||
<NSelect
|
||||
v-model:value="query.taskId"
|
||||
class="notify__task"
|
||||
clearable
|
||||
placeholder="全部任务"
|
||||
:loading="taskLoading"
|
||||
:options="taskOptions"
|
||||
@update:value="search"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="query.bizType"
|
||||
class="notify__biz"
|
||||
clearable
|
||||
placeholder="全部类型"
|
||||
:options="BIZ_TYPES"
|
||||
@update:value="search"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="query.status"
|
||||
class="notify__status"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
:options="statusOptions"
|
||||
@update:value="search"
|
||||
/>
|
||||
<NButton @click="load">
|
||||
<template #icon>
|
||||
<AppIcon name="refresh" :size="16" />
|
||||
</template>
|
||||
刷新
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
&__biz {
|
||||
width: 140px;
|
||||
}
|
||||
<div class="page-card__body notify__body">
|
||||
<!--
|
||||
尺寸口径:横向 `:scroll-x="1000"`(6 列合计),纵向 `flex-height` 撑满 `.page-card__body`
|
||||
—— Naive **没有** Arco 的 `:scroll="{ x, y }"`,照抄会被静默忽略(见 styles/index.scss)。
|
||||
-->
|
||||
<NDataTable
|
||||
remote
|
||||
flex-height
|
||||
size="small"
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:row-key="(row: NotifyRecord) => row.id"
|
||||
:pagination="pagination"
|
||||
:scroll-x="1000"
|
||||
@update:page="onPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
&__status {
|
||||
width: 180px;
|
||||
}
|
||||
<style scoped>
|
||||
/**
|
||||
* 高度链的中间一环由 `contentStyle` 写在卡片内容元素上(原因见 script 里那段说明)。
|
||||
* 这里只保证"卡片内容区里的固定区块不被挤压",剩下的空间全给表格。
|
||||
*/
|
||||
.notify__rate {
|
||||
flex: none;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.notify__task {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.notify__biz {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
&__fail {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--red-6));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.notify__status {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
/* ⚠️ 下面这些类都出现在**渲染函数**里(表格列)。渲染函数生成的元素不带 `data-v-*`,
|
||||
写成 `.notify__muted` 这种普通 scoped 选择器**匹配不到**(不报错,只是样式全丢),
|
||||
所以统一用 `:deep()`。 */
|
||||
:deep(.notify__muted) {
|
||||
font-size: 12px;
|
||||
color: var(--xt-text-3);
|
||||
}
|
||||
|
||||
:deep(.notify__content) {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
:deep(.notify__fail) {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: #d54941;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user