feat(customer-service): 新增客户服务问题反馈管理功能
- 重命名语言文件中相关关键字,统一改为 customer-service_feedbacks - 新增客户服务反馈页面路由及组件导入,移除旧的 www_support_feedbacks 相关路由 - 实现客户服务反馈列表及详情查看,支持状态筛选、分页查询和消息对话展示 - 支持反馈状态变更,包括待处理、处理中和已解决状态切换 - 支持在对话中发送回复消息,且针对已解决状态自动停止轮询 - 优化状态标签样式,采用更易区分的颜色与视觉样式 - 组件及类型定义中新增 NCollapse 和 NCollapseItem 支持 - 移除旧的 www 支持反馈组件代码,清理无用路由及类型定义相关项
This commit is contained in:
@@ -272,7 +272,7 @@ const local: App.I18n.Schema = {
|
||||
www_contact_settings: 'Form Settings',
|
||||
www_contact_submissions: 'Submissions',
|
||||
'customer-service': 'Customer Service',
|
||||
www_support_feedbacks: 'Support Feedback',
|
||||
'customer-service_feedbacks': 'Support Feedback',
|
||||
'www_page-seo': 'Page SEO'
|
||||
},
|
||||
page: {
|
||||
|
||||
@@ -268,8 +268,8 @@ const local: App.I18n.Schema = {
|
||||
www_contact: '联系表单',
|
||||
www_contact_settings: '表单配置',
|
||||
www_contact_submissions: '提交记录',
|
||||
'customer-service': '客户服务',
|
||||
www_support_feedbacks: '问题反馈',
|
||||
'customer-service': '客服服务',
|
||||
'customer-service_feedbacks': '问题反馈',
|
||||
'www_page-seo': '页面 SEO'
|
||||
},
|
||||
page: {
|
||||
|
||||
@@ -20,6 +20,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
500: () => import("@/views/_builtin/500/index.vue"),
|
||||
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
|
||||
login: () => import("@/views/_builtin/login/index.vue"),
|
||||
"customer-service_feedbacks": () => import("@/views/customer-service/feedbacks/index.vue"),
|
||||
headphone_brand: () => import("@/views/headphone/brand/index.vue"),
|
||||
headphone_impedance: () => import("@/views/headphone/impedance/index.vue"),
|
||||
headphone_model: () => import("@/views/headphone/model/index.vue"),
|
||||
@@ -51,5 +52,4 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
www_product_detail: () => import("@/views/www/product/detail/index.vue"),
|
||||
www_product_list: () => import("@/views/www/product/list/index.vue"),
|
||||
www_product_series: () => import("@/views/www/product/series/index.vue"),
|
||||
www_support_feedbacks: () => import("@/views/www/support/feedbacks/index.vue"),
|
||||
};
|
||||
|
||||
@@ -39,6 +39,31 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'customer-service',
|
||||
path: '/customer-service',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'customer-service',
|
||||
i18nKey: 'route.customer-service',
|
||||
icon: 'mdi:headset',
|
||||
order: 7
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'customer-service_feedbacks',
|
||||
path: '/customer-service/feedbacks',
|
||||
component: 'view.customer-service_feedbacks',
|
||||
meta: {
|
||||
title: 'customer-service_feedbacks',
|
||||
i18nKey: 'route.customer-service_feedbacks',
|
||||
icon: 'mdi:message-alert-outline',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'headphone',
|
||||
path: '/headphone',
|
||||
@@ -585,31 +610,6 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'customer-service',
|
||||
path: '/www/support',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'customer-service',
|
||||
i18nKey: 'route.customer-service',
|
||||
icon: 'mdi:headset',
|
||||
order: 7
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'www_support_feedbacks',
|
||||
path: '/www/support/feedbacks',
|
||||
component: 'view.www_support_feedbacks',
|
||||
meta: {
|
||||
title: 'www_support_feedbacks',
|
||||
i18nKey: 'route.www_support_feedbacks',
|
||||
icon: 'mdi:message-alert-outline',
|
||||
order: 1,
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -166,6 +166,8 @@ const routeMap: RouteMap = {
|
||||
"403": "/403",
|
||||
"404": "/404",
|
||||
"500": "/500",
|
||||
"customer-service": "/customer-service",
|
||||
"customer-service_feedbacks": "/customer-service/feedbacks",
|
||||
"headphone": "/headphone",
|
||||
"headphone_brand": "/headphone/brand",
|
||||
"headphone_impedance": "/headphone/impedance",
|
||||
@@ -210,9 +212,7 @@ const routeMap: RouteMap = {
|
||||
"www_product": "/www/product",
|
||||
"www_product_detail": "/www/product/detail",
|
||||
"www_product_list": "/www/product/list",
|
||||
"www_product_series": "/www/product/series",
|
||||
"customer-service": "/www/support",
|
||||
"www_support_feedbacks": "/www/support/feedbacks"
|
||||
"www_product_series": "/www/product/series"
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,6 +41,8 @@ declare module 'vue' {
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
NCollapse: typeof import('naive-ui')['NCollapse']
|
||||
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
|
||||
NColorPicker: typeof import('naive-ui')['NColorPicker']
|
||||
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
NDatePicker: typeof import('naive-ui')['NDatePicker']
|
||||
@@ -132,6 +134,8 @@ declare global {
|
||||
const NButton: typeof import('naive-ui')['NButton']
|
||||
const NCard: typeof import('naive-ui')['NCard']
|
||||
const NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
const NCollapse: typeof import('naive-ui')['NCollapse']
|
||||
const NCollapseItem: typeof import('naive-ui')['NCollapseItem']
|
||||
const NColorPicker: typeof import('naive-ui')['NColorPicker']
|
||||
const NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
const NDatePicker: typeof import('naive-ui')['NDatePicker']
|
||||
|
||||
+4
-4
@@ -20,6 +20,8 @@ declare module "@elegant-router/types" {
|
||||
"403": "/403";
|
||||
"404": "/404";
|
||||
"500": "/500";
|
||||
"customer-service": "/customer-service";
|
||||
"customer-service_feedbacks": "/customer-service/feedbacks";
|
||||
"headphone": "/headphone";
|
||||
"headphone_brand": "/headphone/brand";
|
||||
"headphone_impedance": "/headphone/impedance";
|
||||
@@ -65,8 +67,6 @@ declare module "@elegant-router/types" {
|
||||
"www_product_detail": "/www/product/detail";
|
||||
"www_product_list": "/www/product/list";
|
||||
"www_product_series": "/www/product/series";
|
||||
"customer-service": "/www/support";
|
||||
"www_support_feedbacks": "/www/support/feedbacks";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -101,6 +101,7 @@ declare module "@elegant-router/types" {
|
||||
| "403"
|
||||
| "404"
|
||||
| "500"
|
||||
| "customer-service"
|
||||
| "headphone"
|
||||
| "home"
|
||||
| "iframe-page"
|
||||
@@ -111,7 +112,6 @@ declare module "@elegant-router/types" {
|
||||
| "toolbox"
|
||||
| "upgrade"
|
||||
| "www"
|
||||
| "customer-service"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -133,6 +133,7 @@ declare module "@elegant-router/types" {
|
||||
| "500"
|
||||
| "iframe-page"
|
||||
| "login"
|
||||
| "customer-service_feedbacks"
|
||||
| "headphone_brand"
|
||||
| "headphone_impedance"
|
||||
| "headphone_model"
|
||||
@@ -164,7 +165,6 @@ declare module "@elegant-router/types" {
|
||||
| "www_product_detail"
|
||||
| "www_product_list"
|
||||
| "www_product_series"
|
||||
| "www_support_feedbacks"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
fetchUpdateSupportFeedbackStatus,
|
||||
} from '@/service/api';
|
||||
|
||||
defineOptions({ name: 'WwwSupportFeedbacks' });
|
||||
defineOptions({ name: 'CustomerServiceFeedbacks' });
|
||||
|
||||
const POLL_INTERVAL_MS = 4000;
|
||||
|
||||
@@ -170,10 +170,30 @@ function statusLabel(status: string): string {
|
||||
return t('support.statusPending');
|
||||
}
|
||||
|
||||
function statusClass(status: string): string {
|
||||
if (status === 'resolved') return 'bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800';
|
||||
if (status === 'processing') return 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800';
|
||||
return 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800';
|
||||
function statusMeta(status: string): { badge: string; accent: string; dot: string } {
|
||||
if (status === 'resolved') {
|
||||
return {
|
||||
badge: 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200/80 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-800/60',
|
||||
accent: 'bg-emerald-500',
|
||||
dot: 'bg-emerald-500',
|
||||
};
|
||||
}
|
||||
if (status === 'processing') {
|
||||
return {
|
||||
badge: 'bg-sky-50 text-sky-700 ring-1 ring-sky-200/80 dark:bg-sky-950/40 dark:text-sky-300 dark:ring-sky-800/60',
|
||||
accent: 'bg-sky-500',
|
||||
dot: 'bg-sky-500',
|
||||
};
|
||||
}
|
||||
return {
|
||||
badge: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200/80 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-800/60',
|
||||
accent: 'bg-amber-500',
|
||||
dot: 'bg-amber-500',
|
||||
};
|
||||
}
|
||||
|
||||
function isExpanded(item: SupportFeedbackItem): boolean {
|
||||
return expandedFeedbackNo.value === item.feedback_no;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
@@ -334,91 +354,198 @@ function handleStatusUpdate(feedbackNo: string, status: string) {
|
||||
</form>
|
||||
|
||||
<!-- 我的反馈查询 -->
|
||||
<div class="flex flex-col gap-16px p-24px rounded-12px border border-gray-100 dark:border-dark-700 bg-white dark:bg-dark-800">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('support.myFeedback') }}</h3>
|
||||
<div class="feedback-panel flex flex-col gap-20px p-24px md:p-28px rounded-16px border border-gray-200/80 dark:border-dark-600/80 bg-white dark:bg-dark-800 shadow-sm">
|
||||
<div class="flex flex-col gap-6px">
|
||||
<h3 class="text-lg md:text-xl font-semibold text-gray-900 dark:text-white tracking-tight">
|
||||
{{ t('support.myFeedback') }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('support.queryPlaceholder') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8px">
|
||||
<input
|
||||
v-model="queryInput"
|
||||
type="text"
|
||||
:placeholder="t('support.queryPlaceholder')"
|
||||
class="flex-1 min-w-0 px-16px py-12px rounded-8px border text-sm bg-white dark:bg-dark-800 dark:text-gray-100 outline-none transition-colors focus:border-primary"
|
||||
:class="queryError ? 'border-red-400' : 'border-gray-200 dark:border-dark-600'"
|
||||
@keyup.enter="handleQuery"
|
||||
@input="queryError = ''"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row gap-10px">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<span class="feedback-search-icon pointer-events-none absolute left-14px top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-18px h-18px">
|
||||
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.452 4.391l3.328 3.329a.75.75 0 1 1-1.06 1.06l-3.329-3.328A7 7 0 0 1 2 9Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
v-model="queryInput"
|
||||
type="text"
|
||||
:placeholder="t('support.queryPlaceholder')"
|
||||
class="feedback-search-input w-full pl-42px pr-16px py-13px rounded-12px border text-sm bg-gray-50/80 dark:bg-dark-900/50 dark:text-gray-100 outline-none transition-all placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
:class="queryError
|
||||
? 'border-red-400 focus:border-red-400 focus:ring-2 focus:ring-red-400/20'
|
||||
: 'border-gray-200 dark:border-dark-600 focus:border-primary focus:ring-2 focus:ring-primary/15'"
|
||||
@keyup.enter="handleQuery"
|
||||
@input="queryError = ''"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="querying"
|
||||
class="px-24px py-12px rounded-8px bg-primary text-white text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50 self-start"
|
||||
class="feedback-query-btn shrink-0 px-28px py-13px rounded-12px bg-primary text-white text-sm font-medium shadow-sm hover:shadow-md hover:opacity-95 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="handleQuery"
|
||||
>
|
||||
{{ querying ? t('common.loading') : t('support.query') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="queryError" class="text-xs text-red-500">{{ queryError }}</p>
|
||||
<p v-if="queryError" class="text-xs text-red-500 -mt-8px">{{ queryError }}</p>
|
||||
|
||||
<div v-if="queried && !querying && feedbacks.length === 0" class="py-32px text-center text-sm text-gray-400">
|
||||
{{ t('support.noFeedback') }}
|
||||
<div
|
||||
v-if="queried && !querying && feedbacks.length === 0"
|
||||
class="feedback-empty flex flex-col items-center justify-center gap-12px py-48px px-24px rounded-12px border border-dashed border-gray-200 dark:border-dark-600 bg-gray-50/50 dark:bg-dark-900/30"
|
||||
>
|
||||
<div class="w-48px h-48px rounded-full bg-gray-100 dark:bg-dark-700 flex items-center justify-center text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-24px h-24px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('support.noFeedback') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-12px">
|
||||
<div
|
||||
<div v-else-if="feedbacks.length > 0" class="flex flex-col gap-14px">
|
||||
<article
|
||||
v-for="item in feedbacks"
|
||||
:key="item.id"
|
||||
class="flex flex-col gap-8px p-16px rounded-8px border border-gray-100 dark:border-dark-700 bg-gray-50/50 dark:bg-dark-700/40"
|
||||
class="feedback-card group relative overflow-hidden rounded-14px border transition-all duration-200"
|
||||
:class="isExpanded(item)
|
||||
? 'border-primary/40 dark:border-primary/30 bg-white dark:bg-dark-800 shadow-md ring-1 ring-primary/10'
|
||||
: 'border-gray-200/90 dark:border-dark-600/80 bg-white dark:bg-dark-800/90 hover:border-gray-300 dark:hover:border-dark-500 hover:shadow-sm'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col gap-8px text-left w-full"
|
||||
@click="toggleExpand(item)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<span class="font-medium text-sm text-gray-900 dark:text-white truncate">{{ productName(item) }}</span>
|
||||
<span class="shrink-0 px-10px py-2px rounded-full border text-xs" :class="statusClass(item.status)">
|
||||
<span
|
||||
class="feedback-card-accent absolute left-0 top-0 bottom-0 w-3px"
|
||||
:class="statusMeta(item.status).accent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="p-18px md:p-20px pl-22px md:pl-24px flex flex-col gap-14px">
|
||||
<div class="flex items-start justify-between gap-16px">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-10px mb-8px">
|
||||
<span class="feedback-product-icon shrink-0 w-36px h-36px rounded-10px bg-primary/10 text-primary flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-18px h-18px">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<h4 class="font-semibold text-base text-gray-900 dark:text-white truncate">
|
||||
{{ productName(item) }}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-10px gap-y-6px text-xs text-gray-500 dark:text-gray-400">
|
||||
<time>{{ formatTime(item.created_at) }}</time>
|
||||
<template v-if="item.feedback_no">
|
||||
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
type="button"
|
||||
class="feedback-no-chip inline-flex items-center gap-6px font-mono text-primary hover:text-primary/80 transition-colors"
|
||||
:title="t('support.feedbackNoCopied')"
|
||||
@click="copyFeedbackNo(item.feedback_no)"
|
||||
>
|
||||
{{ item.feedback_no }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-14px h-14px shrink-0 text-primary">
|
||||
<path d="M7 3.5A1.5 1.5 0 0 1 8.5 2h3.879a1.5 1.5 0 0 1 1.06.44l3.122 3.12A1.5 1.5 0 0 1 17 6.622V12.5a1.5 1.5 0 0 1-1.5 1.5h-1v-3.379a3 3 0 0 0-.879-2.121L10.5 5.379A3 3 0 0 0 8.379 4.5H7v-1Z" />
|
||||
<path d="M4.5 6A1.5 1.5 0 0 0 3 7.5v9A1.5 1.5 0 0 0 4.5 18h7a1.5 1.5 0 0 0 1.5-1.5v-5.879a1.5 1.5 0 0 0-.44-1.06L9.44 6.439A1.5 1.5 0 0 0 8.378 6H4.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
v-if="copied && copiedNo === item.feedback_no"
|
||||
class="text-emerald-600 dark:text-emerald-400"
|
||||
>
|
||||
{{ t('support.feedbackNoCopied') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="feedback-status shrink-0 inline-flex items-center gap-6px px-10px py-4px rounded-full text-xs font-medium"
|
||||
:class="statusMeta(item.status).badge"
|
||||
>
|
||||
<span class="w-6px h-6px rounded-full" :class="statusMeta(item.status).dot" />
|
||||
{{ statusLabel(item.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-8px text-xs text-gray-400">
|
||||
<span>{{ formatTime(item.created_at) }}</span>
|
||||
<template v-if="item.feedback_no">
|
||||
<span>·</span>
|
||||
<span
|
||||
class="font-mono text-primary hover:underline"
|
||||
@click.stop="copyFeedbackNo(item.feedback_no)"
|
||||
>
|
||||
{{ item.feedback_no }}
|
||||
</span>
|
||||
<span v-if="copied && copiedNo === item.feedback_no" class="text-green-600 dark:text-green-400">
|
||||
{{ t('support.feedbackNoCopied') }}
|
||||
</span>
|
||||
</template>
|
||||
<span class="ml-auto text-primary">{{ expandedFeedbackNo === item.feedback_no ? t('support.collapseChat') : t('support.expandChat') }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed whitespace-pre-line break-words line-clamp-2">{{ item.description }}</p>
|
||||
</button>
|
||||
<a
|
||||
v-if="item.log_file_path"
|
||||
:href="item.log_file_path"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-primary hover:underline self-start"
|
||||
@click.stop
|
||||
>
|
||||
📎 {{ item.log_file_name }}
|
||||
</a>
|
||||
|
||||
<SupportFeedbackChat
|
||||
v-if="expandedFeedbackNo === item.feedback_no"
|
||||
:item="item"
|
||||
:verified-email="verifiedEmailFor(item)"
|
||||
@verified="(email) => handleEmailVerified(item.feedback_no, email)"
|
||||
@status-update="(status) => handleStatusUpdate(item.feedback_no, status)"
|
||||
/>
|
||||
</div>
|
||||
<div class="rounded-10px bg-gray-50/90 dark:bg-dark-900/45 px-14px py-12px border border-gray-100/80 dark:border-dark-600/60 flex items-start gap-12px">
|
||||
<p
|
||||
class="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-line break-words"
|
||||
:class="isExpanded(item) ? '' : 'line-clamp-2'"
|
||||
>
|
||||
{{ item.description }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="feedback-toggle-btn shrink-0 self-center inline-flex items-center gap-4px px-12px py-7px rounded-8px text-xs font-medium transition-all"
|
||||
:class="isExpanded(item)
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-white text-gray-700 ring-1 ring-gray-200/90 hover:bg-primary/10 hover:text-primary hover:ring-primary/20 dark:bg-dark-700 dark:text-gray-200 dark:ring-dark-500 dark:hover:bg-primary/15 dark:hover:text-primary'"
|
||||
@click="toggleExpand(item)"
|
||||
>
|
||||
{{ isExpanded(item) ? t('support.collapseChat') : t('support.expandChat') }}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-14px h-14px transition-transform duration-200"
|
||||
:class="isExpanded(item) ? 'rotate-180' : ''"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.94a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="item.log_file_path"
|
||||
:href="item.log_file_path"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="feedback-attachment inline-flex items-center gap-6px max-w-full self-start px-10px py-6px rounded-8px text-xs text-gray-600 dark:text-gray-300 bg-gray-100/80 dark:bg-dark-700/80 hover:bg-gray-200/80 dark:hover:bg-dark-600/80 transition-colors truncate"
|
||||
@click.stop
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-14px h-14px shrink-0 text-gray-400">
|
||||
<path fill-rule="evenodd" d="m15.988 3.012-8.25 8.25a2.25 2.25 0 1 1-3.182-3.182l8.25-8.25a3.75 3.75 0 0 1 5.303 5.303l-8.25 8.25a5.25 5.25 0 1 1-7.424-7.424l8.25-8.25a.75.75 0 1 1 1.06 1.06l-8.25 8.25a3.75 3.75 0 0 0 5.303 5.303l8.25-8.25a2.25 2.25 0 0 0-3.182-3.182l-8.25 8.25a.75.75 0 1 1-1.06-1.06l8.25-8.25a3.75 3.75 0 0 0-5.303-5.303Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="truncate">{{ item.log_file_name }}</span>
|
||||
</a>
|
||||
|
||||
<SupportFeedbackChat
|
||||
v-if="isExpanded(item)"
|
||||
:item="item"
|
||||
:verified-email="verifiedEmailFor(item)"
|
||||
@verified="(email) => handleEmailVerified(item.feedback_no, email)"
|
||||
@status-update="(status) => handleStatusUpdate(item.feedback_no, status)"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feedback-panel {
|
||||
background-image: linear-gradient(180deg, rgb(255 255 255 / 1) 0%, rgb(249 250 251 / 0.55) 100%);
|
||||
}
|
||||
|
||||
:root.dark .feedback-panel,
|
||||
.dark .feedback-panel {
|
||||
background-image: linear-gradient(180deg, rgb(30 30 35 / 1) 0%, rgb(24 24 28 / 0.92) 100%);
|
||||
}
|
||||
|
||||
.feedback-card-accent {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.feedback-no-chip:focus-visible,
|
||||
.feedback-toggle-btn:focus-visible,
|
||||
.feedback-query-btn:focus-visible,
|
||||
.feedback-search-input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -143,7 +143,7 @@ onUnmounted(stopPolling);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-12px pt-12px border-t border-gray-100 dark:border-dark-600 flex flex-col gap-12px">
|
||||
<div class="mt-4px pt-16px border-t border-gray-100 dark:border-dark-600 flex flex-col gap-12px">
|
||||
<div v-if="!effectiveEmail" class="flex flex-col gap-8px">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('support.verifyEmailHint') }}</p>
|
||||
<div class="flex gap-8px">
|
||||
|
||||
Reference in New Issue
Block a user