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

<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>