You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
298 lines
11 KiB
298 lines
11 KiB
<template>
|
|
<div class="space-y-5">
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4">
|
|
<div class="flex items-end gap-3 flex-wrap">
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">应用 *</span></label>
|
|
<select v-model="qApp" class="select select-bordered select-sm" style="min-width:180px;" @change="onAppChange">
|
|
<option v-if="!appNames.length" value="">无可用应用</option>
|
|
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">区域 *</span></label>
|
|
<select v-model="qRegion" class="select select-bordered select-sm" style="min-width:130px;" @change="reload">
|
|
<option v-if="!regionItems.length" value="">无可用区域</option>
|
|
<option v-for="a in regionItems" :key="a.id" :value="a.id">{{ regionLabel(a.region) }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control flex-1" style="min-width:220px;">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">用户</span></label>
|
|
<input
|
|
v-model="q.user" type="text" class="input input-bordered input-sm w-full"
|
|
placeholder="UID(精确) / 手机号 / 邮箱 / 用户名 / 登录账号" @keydown.enter.prevent="reload" />
|
|
</div>
|
|
<div class="form-control flex-1" style="min-width:180px;">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">内容</span></label>
|
|
<input
|
|
v-model="q.content" type="text" class="input input-bordered input-sm w-full"
|
|
placeholder="反馈正文关键词" @keydown.enter.prevent="reload" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">提交起</span></label>
|
|
<input v-model="q.startDate" type="date" class="input input-bordered input-sm" @change="reload" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">提交止</span></label>
|
|
<input v-model="q.endDate" type="date" class="input input-bordered input-sm" @change="reload" />
|
|
</div>
|
|
<button class="btn btn-primary btn-sm text-white" @click="reload">查询</button>
|
|
<button class="btn btn-ghost btn-sm" @click="resetFilters">重置</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
|
|
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-100">
|
|
<h3 class="text-sm font-semibold text-slate-700">
|
|
用户反馈<span class="text-xs text-slate-400 font-normal ml-2">按提交时间倒序,点一行看全文</span>
|
|
</h3>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs text-slate-500">共 {{ total }} 条</span>
|
|
<select v-model.number="q.pageSize" class="select select-bordered select-xs" @change="reload">
|
|
<option :value="20">20/页</option>
|
|
<option :value="50">50/页</option>
|
|
<option :value="100">100/页</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>时间</th><th>用户</th><th>内容</th><th>版本</th><th>机型</th><th>渠道</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="loading"><td colspan="6" class="text-center py-10"><span class="loading loading-spinner loading-sm"></span></td></tr>
|
|
<tr v-else-if="!qRegion"><td colspan="6" class="text-center text-slate-400 py-10">请先选择应用与区域</td></tr>
|
|
<tr v-else-if="listError"><td colspan="6" class="text-center text-red-400 py-10">{{ listError }}</td></tr>
|
|
<tr v-else-if="!rows.length"><td colspan="6" class="text-center text-slate-400 py-10">没有符合条件的反馈</td></tr>
|
|
<tr v-for="r in rows" v-else :key="r.id" class="hover cursor-pointer" @click="openDetail(r)">
|
|
<td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTs(r.createtime) }}</td>
|
|
<td class="text-xs">
|
|
<div class="font-medium text-slate-700">{{ r.name || '(未设置昵称)' }}</div>
|
|
<div class="text-slate-400 font-mono">{{ r.uid }}</div>
|
|
<div v-if="r.phone" class="text-slate-500">{{ r.phone }}</div>
|
|
<div v-if="r.mail" class="text-slate-500 truncate max-w-[180px]" :title="r.mail">{{ r.mail }}</div>
|
|
</td>
|
|
<td class="text-xs text-slate-700 max-w-[360px]">
|
|
<div class="line-clamp-2 whitespace-pre-wrap break-words">{{ r.message }}</div>
|
|
</td>
|
|
<td class="text-xs text-slate-500 whitespace-nowrap">{{ r.version || '-' }}</td>
|
|
<td class="text-xs text-slate-500 max-w-[140px] truncate" :title="r.phonemodel">{{ r.phonemodel || '-' }}</td>
|
|
<td class="text-xs text-slate-500 whitespace-nowrap">{{ channelName(r.channel) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="flex items-center justify-between px-5 py-3 border-t border-slate-100">
|
|
<span class="text-xs text-slate-500">{{ pageInfo }}</span>
|
|
<div class="flex items-center gap-2">
|
|
<button class="btn btn-ghost btn-xs" :disabled="q.page <= 0 || loading" @click="changePage(-1)">上一页</button>
|
|
<span class="text-xs text-slate-500">第 {{ q.page + 1 }} / {{ totalPages }} 页</span>
|
|
<button class="btn btn-ghost btn-xs" :disabled="q.page + 1 >= totalPages || loading" @click="changePage(1)">下一页</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<dialog ref="detailModal" class="modal">
|
|
<div class="modal-box max-w-2xl">
|
|
<h3 class="font-semibold text-slate-800">反馈全文</h3>
|
|
<div v-if="current" class="mt-3 space-y-3">
|
|
<div class="text-xs text-slate-500 flex flex-wrap gap-x-4 gap-y-1">
|
|
<span>{{ fmtTs(current.createtime) }}</span>
|
|
<span>{{ current.name || '(未设置昵称)' }}</span>
|
|
<span class="font-mono">{{ current.uid }}</span>
|
|
<span v-if="current.phone">{{ current.phone }}</span>
|
|
<span v-if="current.mail">{{ current.mail }}</span>
|
|
<span v-if="current.account">账号 {{ current.account }}</span>
|
|
<span>版本 {{ current.version || '-' }}</span>
|
|
<span>{{ current.phonemodel || '-' }}</span>
|
|
<span>{{ channelName(current.channel) }}</span>
|
|
</div>
|
|
<div class="text-sm text-slate-800 whitespace-pre-wrap break-words bg-slate-50 rounded-lg p-4">{{ current.message }}</div>
|
|
</div>
|
|
<div class="modal-action">
|
|
<button class="btn btn-sm" @click="closeDetail">关闭</button>
|
|
</div>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
|
</dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useAuthStore } from '~/stores/auth'
|
|
import { regionLabel } from '~/utils/regions'
|
|
|
|
definePageMeta({ title: '用户反馈' })
|
|
|
|
interface AppItem {
|
|
id: number
|
|
name: string
|
|
app_name?: string
|
|
region: string
|
|
enabled?: boolean
|
|
}
|
|
interface FeedbackRow {
|
|
id: number
|
|
uid: string
|
|
message: string
|
|
version: string
|
|
phonemodel: string
|
|
channel: number
|
|
createtime: number
|
|
name: string
|
|
phone: string
|
|
mail: string
|
|
account: string
|
|
}
|
|
|
|
const CHANNELS: Record<number, string> = {
|
|
1: '华为', 2: '小米', 3: 'OPPO', 4: 'VIVO', 5: '荣耀',
|
|
100: '官方包', 101: 'App Store', 102: 'Google Play',
|
|
}
|
|
function channelName(v: number) {
|
|
if (!v) return '-'
|
|
return CHANNELS[v] ?? '渠道' + v
|
|
}
|
|
|
|
const { webApi, consoleApi } = useApi()
|
|
const auth = useAuthStore()
|
|
|
|
const apps = ref<AppItem[]>([])
|
|
const qApp = ref('')
|
|
const qRegion = ref<number | ''>('')
|
|
const rows = ref<FeedbackRow[]>([])
|
|
const total = ref(0)
|
|
const loading = ref(false)
|
|
const listError = ref('')
|
|
const q = reactive({ user: '', content: '', startDate: '', endDate: '', page: 0, pageSize: 20 })
|
|
const current = ref<FeedbackRow | null>(null)
|
|
const detailModal = ref<HTMLDialogElement | null>(null)
|
|
|
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / q.pageSize)))
|
|
const pageInfo = computed(() => {
|
|
if (!total.value) return '-'
|
|
const from = q.page * q.pageSize + 1
|
|
return `第 ${from} - ${Math.min(from + rows.value.length - 1, total.value)} 条 / 共 ${total.value} 条`
|
|
})
|
|
const appNames = computed(() => {
|
|
const names: string[] = []
|
|
apps.value.forEach((a) => {
|
|
const n = a.app_name || a.name
|
|
if (n && !names.includes(n)) names.push(n)
|
|
})
|
|
return names
|
|
})
|
|
const regionItems = computed(() => apps.value.filter((a) => (a.app_name || a.name) === qApp.value))
|
|
|
|
function onAppChange() {
|
|
const items = regionItems.value
|
|
qRegion.value = items.length ? items[0].id : ''
|
|
reload()
|
|
}
|
|
function fmtTs(ts: number) {
|
|
if (!ts) return '-'
|
|
return new Date(ts * 1000).toLocaleString()
|
|
}
|
|
function dateToUnix(s: string, endOfDay: boolean): number {
|
|
if (!s) return 0
|
|
const d = new Date(s + 'T00:00:00')
|
|
if (isNaN(d.getTime())) return 0
|
|
return Math.floor(d.getTime() / 1000) + (endOfDay ? 86400 : 0)
|
|
}
|
|
|
|
async function loadList() {
|
|
if (!qApp.value || !qRegion.value) {
|
|
rows.value = []
|
|
total.value = 0
|
|
return
|
|
}
|
|
auth.setCurrentApp(Number(qRegion.value))
|
|
loading.value = true
|
|
listError.value = ''
|
|
try {
|
|
const d = await webApi<{ items: FeedbackRow[]; total: number }>('getuserfeedbacks', {
|
|
user: q.user.trim(),
|
|
content: q.content.trim(),
|
|
start: dateToUnix(q.startDate, false),
|
|
end: dateToUnix(q.endDate, true),
|
|
page: q.page,
|
|
page_size: q.pageSize,
|
|
})
|
|
rows.value = d?.items ?? []
|
|
total.value = d?.total ?? 0
|
|
} catch (e: any) {
|
|
listError.value = e?.message || '查询失败'
|
|
rows.value = []
|
|
total.value = 0
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
function reload() {
|
|
q.page = 0
|
|
loadList()
|
|
}
|
|
function changePage(d: number) {
|
|
const next = q.page + d
|
|
if (next < 0 || next >= totalPages.value) return
|
|
q.page = next
|
|
loadList()
|
|
}
|
|
function resetFilters() {
|
|
q.user = ''
|
|
q.content = ''
|
|
q.startDate = ''
|
|
q.endDate = ''
|
|
reload()
|
|
}
|
|
function openDetail(row: FeedbackRow) {
|
|
current.value = row
|
|
detailModal.value?.showModal()
|
|
}
|
|
function closeDetail() {
|
|
detailModal.value?.close()
|
|
}
|
|
|
|
function buildAppSelect() {
|
|
const names = appNames.value
|
|
if (!names.length) {
|
|
qApp.value = ''
|
|
qRegion.value = ''
|
|
return
|
|
}
|
|
qApp.value = names[0]
|
|
const items = regionItems.value
|
|
qRegion.value = items.length ? items[0].id : ''
|
|
}
|
|
|
|
async function loadApps() {
|
|
if (!auth.isAdmin) {
|
|
try {
|
|
const d = await webApi<{ apps: AppItem[] }>('getmyapps', {})
|
|
apps.value = d?.apps ?? []
|
|
} catch {
|
|
apps.value = (auth.appbinds || []).map((a) => ({
|
|
id: a.id, name: a.name, app_name: a.app_name, region: a.region, enabled: true,
|
|
}))
|
|
}
|
|
buildAppSelect()
|
|
loadList()
|
|
return
|
|
}
|
|
try {
|
|
const list = await consoleApi<AppItem[]>('apps/list', {})
|
|
apps.value = (list || []).filter((a) => a.enabled)
|
|
} catch {
|
|
apps.value = []
|
|
}
|
|
buildAppSelect()
|
|
loadList()
|
|
}
|
|
|
|
onMounted(loadApps)
|
|
</script>
|
|
|