feat(admin): 分站二维码池支持搜索筛选并记住上次选择的分站

- 工具栏对齐主站二维码池:关键词搜索、绑定群、过期状态、上传时间范围
- fetchSiteQrCodes 补充传递 bound/expire_status/created_start/created_end
- 按管理员维度记忆上次查看的分站,站点失效时回退到首个站点
This commit is contained in:
yml2213
2026-10-12 17:11:06 +08:00
parent cca40940d5
commit d9003851bd
2 changed files with 151 additions and 11 deletions
@@ -182,6 +182,10 @@ export async function fetchSiteQrCodes(siteId: number, query: QrCodeListQuery =
params: {
status: query.status || undefined,
keyword: query.keyword?.trim() || undefined,
bound: query.bound || undefined,
expire_status: query.expire_status || undefined,
created_start: query.created_start || undefined,
created_end: query.created_end || undefined,
page: query.page || 1,
limit: query.limit || 20,
},
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Plus, Refresh, UploadFilled } from '@element-plus/icons-vue'
import { Delete, Plus, Refresh, Search, UploadFilled } from '@element-plus/icons-vue'
import {
batchCreateSiteQrCodes,
@@ -13,6 +13,8 @@ import {
updateSiteQrCode,
type ChatQrCode,
type CreateQrCodePayload,
type QrCodeBooleanFilter,
type QrCodeExpireStatus,
type QrCodeStats,
type QrCodeStatus,
} from '@/features/admin/api/adminQrCodes'
@@ -38,7 +40,38 @@ const qrcodes = ref<ChatQrCode[]>([])
const total = ref(0)
const currentPage = ref(1)
const currentPageSize = ref(20)
const statusFilter = ref<QrCodeStatus | ''>('')
const filters = reactive<{
keyword: string
status: QrCodeStatus | ''
bound: QrCodeBooleanFilter | ''
expire_status: QrCodeExpireStatus | ''
created_range: string[]
}>({
keyword: '',
status: '',
bound: '',
expire_status: '',
created_range: [],
})
const boundFilterOptions: { label: string; value: QrCodeBooleanFilter }[] = [
{ label: '已绑定', value: 'true' },
{ label: '未绑定', value: 'false' },
]
const expireStatusOptions: { label: string; value: QrCodeExpireStatus }[] = [
{ label: '未过期', value: 'valid' },
{ label: '即将过期', value: 'expiring_soon' },
{ label: '已过期', value: 'expired' },
{ label: '永久有效', value: 'permanent' },
]
function filterDateToISO(value?: string) {
if (!value) return undefined
const date = new Date(value)
if (Number.isNaN(date.getTime())) return undefined
return date.toISOString()
}
const stats = ref<QrCodeStats>({
unused_count: 0,
used_count: 0,
@@ -76,13 +109,38 @@ const maxBatchUploadCount = 20
const selectedSite = computed(() => sites.value.find(site => site.id === siteId.value))
// 记住每个管理员上次查看的分站,下次进页面直接落到该站点
const siteMemoryKey = computed(() => `admin_distro_qrcode_last_site_${adminSession.adminId || 0}`)
function readRememberedSiteId() {
try {
const raw = localStorage.getItem(siteMemoryKey.value)
const parsed = Number(raw)
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
} catch {
return undefined
}
}
function rememberSiteId(id?: number) {
try {
if (id) localStorage.setItem(siteMemoryKey.value, String(id))
else localStorage.removeItem(siteMemoryKey.value)
} catch {
// localStorage 不可用(隐私模式等)时静默降级,不影响使用
}
}
async function loadSites() {
try {
sites.value = await fetchDistributionSites()
const firstSite = sites.value[0]
if (!siteId.value && firstSite) {
siteId.value = firstSite.id
}
const rememberedId = readRememberedSiteId()
const rememberedSite = rememberedId
? sites.value.find(site => site.id === rememberedId)
: undefined
// 记忆站点已不存在(被删除/无权限)时回退到第一个站点
siteId.value = rememberedSite?.id ?? sites.value[0]?.id
if (siteId.value) rememberSiteId(siteId.value)
} catch (error) {
ElMessage.error(readError(error, '加载分销站点失败'))
}
@@ -98,7 +156,12 @@ async function loadPool() {
try {
const [list, nextStats] = await Promise.all([
fetchSiteQrCodes(siteId.value, {
status: statusFilter.value || undefined,
status: filters.status || undefined,
keyword: filters.keyword.trim() || undefined,
bound: filters.bound || undefined,
expire_status: filters.expire_status || undefined,
created_start: filterDateToISO(filters.created_range?.[0]),
created_end: filterDateToISO(filters.created_range?.[1]),
page: currentPage.value,
limit: currentPageSize.value,
}),
@@ -113,6 +176,7 @@ async function loadPool() {
}
function handleSiteChange() {
rememberSiteId(siteId.value)
currentPage.value = 1
loadPool()
}
@@ -122,6 +186,15 @@ function handleFilter() {
loadPool()
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.bound = ''
filters.expire_status = ''
filters.created_range = []
handleFilter()
}
async function recognizeGroupName(file: File) {
const { job_id } = await submitOCRJob(file)
const delays = [1000, 2000, 3000, 3000, 3000]
@@ -294,7 +367,7 @@ onBeforeUnmount(() => {
/>
</el-select>
<el-select
v-model="statusFilter"
v-model="filters.status"
placeholder="状态"
clearable
class="status-select"
@@ -307,6 +380,56 @@ onBeforeUnmount(() => {
:value="option.value"
/>
</el-select>
<el-input
v-model="filters.keyword"
clearable
class="keyword-input"
:prefix-icon="Search"
placeholder="群名 / 备注 / 绑定群 / ID"
@clear="handleFilter"
@keyup.enter="handleFilter"
/>
<el-select
v-model="filters.bound"
placeholder="绑定群"
clearable
class="compact-select"
@change="handleFilter"
>
<el-option
v-for="opt in boundFilterOptions"
:key="`bound-${opt.value}`"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select
v-model="filters.expire_status"
placeholder="过期状态"
clearable
class="compact-select"
@change="handleFilter"
>
<el-option
v-for="opt in expireStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-date-picker
v-model="filters.created_range"
type="datetimerange"
range-separator="至"
start-placeholder="上传开始"
end-placeholder="上传结束"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DDTHH:mm:ss"
class="date-range-filter"
@change="handleFilter"
/>
<el-button type="primary" :icon="Search" @click="handleFilter">筛选</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button :icon="Refresh" @click="loadPool">刷新</el-button>
</div>
@@ -466,12 +589,25 @@ onBeforeUnmount(() => {
align-items: center;
}
.toolbar {
flex-wrap: wrap;
}
.site-select {
width: 280px;
}
.status-select {
width: 140px;
.status-select,
.compact-select {
width: 130px;
}
.keyword-input {
width: 220px;
}
.date-range-filter {
max-width: 360px;
}
.stats div {