518 lines
22 KiB
Vue
518 lines
22 KiB
Vue
<template>
|
||
<view class="page employee-page-shell direct-page">
|
||
<view class="subpage-header">
|
||
<button class="subpage-back-button" aria-label="返回公司消息" @click="goBack">
|
||
<uni-icons type="back" size="20" color="#25282d" />
|
||
</button>
|
||
<view class="subpage-heading">
|
||
<text class="employee-kicker">点对点反馈</text>
|
||
<text class="page-title">直通车</text>
|
||
<text class="page-subtitle">选择对象后直接提交,处理结果会回到“我的反馈”。</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="!loggedIn" class="panel employee-card login-panel">
|
||
<text class="section-title">登录后使用</text>
|
||
<text class="muted">直达反馈只接受当前在职员工提交。</text>
|
||
<button class="primary-button" @click="goLogin">去登录</button>
|
||
</view>
|
||
|
||
<template v-else>
|
||
<view class="panel employee-card submit-card">
|
||
<view class="section-head">
|
||
<view>
|
||
<text class="section-title">选择直达对象</text>
|
||
<text class="section-note">每次只提交给一个处理部门。</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="channelsLoading" class="channel-state" role="status" aria-live="polite">
|
||
<text>直达对象加载中…</text>
|
||
</view>
|
||
<view v-else-if="channelsFailed" class="channel-state error" role="alert">
|
||
<text>直达对象加载失败,请检查网络后重试。</text>
|
||
<button class="secondary-button channel-retry" :disabled="channelsLoading" @click="loadChannels">
|
||
重新加载直达对象
|
||
</button>
|
||
</view>
|
||
<view v-else class="channel-grid" role="radiogroup" aria-label="直达对象">
|
||
<button
|
||
v-for="channel in channels"
|
||
:key="channel.code"
|
||
class="channel-button"
|
||
:class="{ active: form.channelCode === channel.code }"
|
||
role="radio"
|
||
:aria-checked="form.channelCode === channel.code"
|
||
@click="chooseChannel(channel)"
|
||
>
|
||
<view class="channel-icon">
|
||
<uni-icons
|
||
:type="channelIcon(channel.code)"
|
||
size="19"
|
||
:color="form.channelCode === channel.code ? '#e60012' : '#647083'"
|
||
/>
|
||
</view>
|
||
<text class="channel-name">{{ channel.name }}</text>
|
||
<text v-if="channel.defaultAnonymous" class="channel-hint">默认匿名</text>
|
||
</button>
|
||
</view>
|
||
|
||
<view class="field-head">
|
||
<label class="field-label" for="direct-content">反馈内容</label>
|
||
<button
|
||
class="voice-chip"
|
||
:class="{ recording: voiceRecording, busy: voiceTranscribing || voiceStarting || voiceStopping }"
|
||
:disabled="voiceTranscribing || voiceStarting || voiceStopping"
|
||
:aria-pressed="voiceRecording"
|
||
aria-label="语音输入"
|
||
@click="toggleVoiceCapture"
|
||
>
|
||
<uni-icons
|
||
:type="voiceRecording ? 'mic-filled' : 'mic'"
|
||
size="15"
|
||
:color="voiceRecording ? '#ffffff' : '#e60012'"
|
||
/>
|
||
<text>{{ voiceChipLabel }}</text>
|
||
</button>
|
||
</view>
|
||
<textarea
|
||
id="direct-content"
|
||
v-model="form.content"
|
||
class="feedback-input"
|
||
:maxlength="MAX_CONTENT_LENGTH"
|
||
placeholder="请说明具体情况、影响和希望得到的处理。"
|
||
aria-label="反馈内容"
|
||
/>
|
||
<view
|
||
v-if="voiceBusy || voiceHint"
|
||
class="voice-status"
|
||
:class="{ recording: voiceRecording }"
|
||
role="status"
|
||
aria-live="polite"
|
||
>
|
||
<uni-icons v-if="voiceRecording" type="sound" size="13" color="#e60012" />
|
||
<text>{{ voiceStatusText }}</text>
|
||
</view>
|
||
<view class="form-foot">
|
||
<label class="anonymous-control">
|
||
<switch
|
||
:checked="form.anonymous"
|
||
color="#c91825"
|
||
aria-label="匿名提交"
|
||
@change="changeAnonymous"
|
||
/>
|
||
<view class="anonymous-icon">
|
||
<uni-icons type="eye-slash" size="16" color="#925b00" />
|
||
</view>
|
||
<view>
|
||
<text class="anonymous-title">匿名提交</text>
|
||
<text class="section-note">处理端不显示提交人;系统内部仍保留审计记录。</text>
|
||
</view>
|
||
</label>
|
||
<text class="counter">{{ form.content.length }}/{{ MAX_CONTENT_LENGTH }}</text>
|
||
</view>
|
||
<text v-if="submitMessage" class="status-message" :class="{ error: submitFailed }" role="status" aria-live="polite">
|
||
{{ submitMessage }}
|
||
</text>
|
||
<button class="primary-button submit-button" :disabled="submitting || voiceBusy || !canSubmit" @click="submit">
|
||
{{ submitting ? '提交中…' : '确认提交' }}
|
||
</button>
|
||
</view>
|
||
|
||
<view class="history-head">
|
||
<view>
|
||
<text class="section-title">我的反馈</text>
|
||
<text class="section-note">只有你本人可以查看提交内容和正式回复。</text>
|
||
</view>
|
||
<button class="refresh-button" :disabled="loading" @click="loadMine(true)">刷新</button>
|
||
</view>
|
||
<view v-if="loading" class="panel employee-card loading-card" role="status">反馈加载中…</view>
|
||
<view v-else-if="loadFailed" class="panel employee-card empty-card" role="alert">
|
||
<text class="section-title">反馈加载失败</text>
|
||
<button class="secondary-button" @click="loadMine(true)">重新加载</button>
|
||
</view>
|
||
<view v-else-if="!items.length" class="panel employee-card empty-card">
|
||
<uni-icons type="chatboxes" size="36" color="#c3c9d4" />
|
||
<text class="section-title">还没有反馈</text>
|
||
<text class="muted">提交后会在这里显示处理状态。</text>
|
||
</view>
|
||
<view v-else class="feedback-list">
|
||
<view v-for="item in items" :key="item.id" class="panel employee-card feedback-card">
|
||
<view class="feedback-head">
|
||
<view class="feedback-tags">
|
||
<text class="channel-tag">{{ item.channelName }}</text>
|
||
<text v-if="item.anonymous" class="anonymous-tag">匿名</text>
|
||
</view>
|
||
<text class="status-tag" :class="{ replied: item.status === 'REPLIED' }">
|
||
{{ item.status === 'REPLIED' ? '已回复' : '待处理' }}
|
||
</text>
|
||
</view>
|
||
<text class="feedback-content">{{ item.content }}</text>
|
||
<view v-if="item.replyContent" class="reply-box">
|
||
<text class="reply-label">正式回复</text>
|
||
<text class="reply-content">{{ item.replyContent }}</text>
|
||
</view>
|
||
<text class="feedback-time">{{ displayTime(item.createdAt) }}</text>
|
||
</view>
|
||
<button v-if="hasMore" class="secondary-button load-more" :disabled="loadingMore" @click="loadMine(false)">
|
||
{{ loadingMore ? '加载中…' : '加载更多' }}
|
||
</button>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, reactive, ref } from 'vue';
|
||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||
import {
|
||
listDirectChannels,
|
||
listMyDirectFeedback,
|
||
submitDirectFeedback,
|
||
type DirectChannel,
|
||
type DirectChannelCode,
|
||
type DirectFeedback
|
||
} from '@/services/direct';
|
||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||
import { resetPageScroll } from '@/services/navigation';
|
||
import { createPageRequestScope } from '@/services/request-scope';
|
||
import { startSpeechCapture, type SpeechCapture, type SpeechCaptureSession } from '@/services/speech-capture';
|
||
import { transcribeSpeechCapture } from '@/services/speech';
|
||
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
|
||
|
||
const path = '/pages/user/direct/index';
|
||
const pageSize = 20;
|
||
const loggedIn = ref(false);
|
||
const channels = ref<DirectChannel[]>([]);
|
||
const channelsLoading = ref(false);
|
||
const channelsFailed = ref(false);
|
||
const items = ref<DirectFeedback[]>([]);
|
||
const total = ref(0);
|
||
const pageNum = ref(1);
|
||
const loading = ref(false);
|
||
const loadingMore = ref(false);
|
||
const loadFailed = ref(false);
|
||
const submitting = ref(false);
|
||
const submitFailed = ref(false);
|
||
const submitMessage = ref('');
|
||
const requests = createPageRequestScope();
|
||
const form = reactive({
|
||
requestId: '',
|
||
channelCode: '' as DirectChannelCode | '',
|
||
anonymous: false,
|
||
content: ''
|
||
});
|
||
|
||
const MAX_CONTENT_LENGTH = 5000;
|
||
const voiceRecording = ref(false);
|
||
const voiceStarting = ref(false);
|
||
const voiceStopping = ref(false);
|
||
const voiceTranscribing = ref(false);
|
||
const voiceSeconds = ref(0);
|
||
const voiceHint = ref('');
|
||
let voiceCapture: SpeechCaptureSession | null = null;
|
||
let voiceGeneration = 0;
|
||
let abortVoiceTranscription: (() => void) | null = null;
|
||
let voiceTimer: ReturnType<typeof setInterval> | null = null;
|
||
let voiceStartedAt = 0;
|
||
|
||
const hasMore = computed(() => items.value.length < total.value);
|
||
const canSubmit = computed(() =>
|
||
!channelsLoading.value
|
||
&& !channelsFailed.value
|
||
&& Boolean(form.channelCode)
|
||
&& Boolean(form.content.trim())
|
||
);
|
||
const voiceBusy = computed(() => voiceStarting.value || voiceRecording.value || voiceStopping.value || voiceTranscribing.value);
|
||
const voiceChipLabel = computed(() => {
|
||
if (voiceStopping.value) return '结束中…';
|
||
if (voiceRecording.value) return `停止 ${formatVoiceSeconds(voiceSeconds.value)}`;
|
||
if (voiceTranscribing.value) return '转写中…';
|
||
if (voiceStarting.value) return '启动中…';
|
||
return '语音输入';
|
||
});
|
||
const voiceStatusText = computed(() => {
|
||
if (voiceStopping.value) return '录音结束,正在转写';
|
||
if (voiceRecording.value) return '正在录音,再次点击结束';
|
||
if (voiceTranscribing.value) return '语音转写中…';
|
||
if (voiceStarting.value) return '正在启动麦克风…';
|
||
return voiceHint.value;
|
||
});
|
||
|
||
const channelIcons: Record<DirectChannelCode, string> = {
|
||
PRESIDENT: 'vip',
|
||
FINANCE: 'wallet',
|
||
HR: 'staff',
|
||
AUDIT: 'eye',
|
||
OPERATIONS: 'gear'
|
||
};
|
||
// 后端可能新增渠道 code,映射缺失时兜底 paperplane,避免图标渲染为空。
|
||
const channelIcon = (code: DirectChannelCode) => channelIcons[code] ?? 'paperplane';
|
||
|
||
const formatVoiceSeconds = (total: number) => {
|
||
const minutes = Math.floor(total / 60);
|
||
const seconds = total % 60;
|
||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||
};
|
||
|
||
const stopVoiceTimer = () => {
|
||
if (voiceTimer) clearInterval(voiceTimer);
|
||
voiceTimer = null;
|
||
};
|
||
|
||
const startVoiceTimer = () => {
|
||
stopVoiceTimer();
|
||
voiceStartedAt = Date.now();
|
||
voiceSeconds.value = 0;
|
||
voiceTimer = setInterval(() => {
|
||
voiceSeconds.value = Math.floor((Date.now() - voiceStartedAt) / 1000);
|
||
}, 500);
|
||
};
|
||
|
||
const appendTranscript = (text: string) => {
|
||
const current = form.content.trim() ? form.content : '';
|
||
const joined = current ? `${current}\n${text}` : text;
|
||
if (joined.length > MAX_CONTENT_LENGTH) {
|
||
form.content = joined.slice(0, MAX_CONTENT_LENGTH);
|
||
voiceHint.value = `内容已达 ${MAX_CONTENT_LENGTH} 字上限,超出部分已截断`;
|
||
return;
|
||
}
|
||
form.content = joined;
|
||
voiceHint.value = '语音已转成文字,请确认后提交';
|
||
};
|
||
|
||
const handleVoiceCapture = async (capture: SpeechCapture) => {
|
||
const generation = ++voiceGeneration;
|
||
voiceTranscribing.value = true;
|
||
try {
|
||
const asr = await transcribeSpeechCapture(capture, 'direct-feedback.webm', (abort) => {
|
||
if (generation === voiceGeneration) abortVoiceTranscription = abort;
|
||
});
|
||
if (generation !== voiceGeneration) return;
|
||
const text = (asr.text || '').trim();
|
||
if (!text) {
|
||
voiceHint.value = '语音没有识别到文字,请再说一遍';
|
||
return;
|
||
}
|
||
appendTranscript(text);
|
||
} catch (error) {
|
||
if (generation !== voiceGeneration) return;
|
||
voiceHint.value = error instanceof Error && error.message ? error.message : '语音转写失败,请改用文字输入';
|
||
} finally {
|
||
if (generation === voiceGeneration) {
|
||
abortVoiceTranscription = null;
|
||
voiceTranscribing.value = false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const startVoiceCapture = async () => {
|
||
if (voiceStarting.value || voiceRecording.value || voiceTranscribing.value) return;
|
||
const generation = ++voiceGeneration;
|
||
voiceStarting.value = true;
|
||
voiceHint.value = '';
|
||
try {
|
||
const session = await startSpeechCapture({
|
||
onCapture: (capture) => {
|
||
if (generation !== voiceGeneration) return;
|
||
voiceCapture = null;
|
||
voiceRecording.value = false;
|
||
voiceStopping.value = false;
|
||
stopVoiceTimer();
|
||
void handleVoiceCapture(capture);
|
||
},
|
||
onError: () => {
|
||
if (generation !== voiceGeneration) return;
|
||
voiceCapture = null;
|
||
voiceRecording.value = false;
|
||
voiceStopping.value = false;
|
||
stopVoiceTimer();
|
||
voiceHint.value = '麦克风不可用,请检查权限后直接输入文字';
|
||
}
|
||
});
|
||
if (!session || generation !== voiceGeneration) {
|
||
session?.cancel();
|
||
if (generation === voiceGeneration) voiceHint.value = '麦克风不可用,请检查权限后直接输入文字';
|
||
return;
|
||
}
|
||
voiceCapture = session;
|
||
voiceRecording.value = true;
|
||
startVoiceTimer();
|
||
} catch {
|
||
if (generation === voiceGeneration) voiceHint.value = '麦克风不可用,请检查权限后直接输入文字';
|
||
} finally {
|
||
if (generation === voiceGeneration) voiceStarting.value = false;
|
||
}
|
||
};
|
||
|
||
const stopVoiceCapture = () => {
|
||
if (!voiceRecording.value || voiceStopping.value || !voiceCapture) return;
|
||
voiceStopping.value = true;
|
||
stopVoiceTimer();
|
||
voiceCapture.stop();
|
||
};
|
||
|
||
const toggleVoiceCapture = () => {
|
||
if (voiceStarting.value || voiceStopping.value || voiceTranscribing.value) return;
|
||
if (voiceRecording.value) {
|
||
stopVoiceCapture();
|
||
return;
|
||
}
|
||
void startVoiceCapture();
|
||
};
|
||
|
||
const teardownVoice = () => {
|
||
voiceGeneration += 1;
|
||
abortVoiceTranscription?.();
|
||
abortVoiceTranscription = null;
|
||
voiceTranscribing.value = false;
|
||
voiceStarting.value = false;
|
||
voiceStopping.value = false;
|
||
voiceCapture?.cancel();
|
||
voiceCapture = null;
|
||
voiceRecording.value = false;
|
||
stopVoiceTimer();
|
||
voiceSeconds.value = 0;
|
||
voiceHint.value = '';
|
||
};
|
||
|
||
const createRequestId = () =>
|
||
globalThis.crypto?.randomUUID?.() ?? `direct-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
|
||
|
||
const goBack = () => uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: '/pages/user/broadcast/index' }) });
|
||
const goLogin = () => {
|
||
rememberLoginRedirect(path);
|
||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||
};
|
||
const displayTime = (value?: string) => value ? value.replace('T', ' ').slice(0, 16) : '';
|
||
|
||
const chooseChannel = (channel: DirectChannel) => {
|
||
form.channelCode = channel.code;
|
||
form.anonymous = channel.defaultAnonymous;
|
||
};
|
||
|
||
const changeAnonymous = (event: Event | { detail?: { value?: boolean } }) => {
|
||
form.anonymous = Boolean('detail' in event ? event.detail?.value : false);
|
||
};
|
||
|
||
const loadChannels = async () => {
|
||
if (channelsLoading.value) return;
|
||
channelsLoading.value = true;
|
||
channelsFailed.value = false;
|
||
try {
|
||
const rows = await listDirectChannels();
|
||
if (!rows.length) {
|
||
channels.value = [];
|
||
form.channelCode = '';
|
||
channelsFailed.value = true;
|
||
return;
|
||
}
|
||
channels.value = rows;
|
||
if (!form.channelCode || !rows.some((channel) => channel.code === form.channelCode)) {
|
||
chooseChannel(rows[0]);
|
||
}
|
||
} catch {
|
||
channels.value = [];
|
||
form.channelCode = '';
|
||
channelsFailed.value = true;
|
||
} finally {
|
||
channelsLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const loadMine = async (reset: boolean) => {
|
||
if (loading.value || loadingMore.value) return;
|
||
const nextPage = reset ? 1 : pageNum.value + 1;
|
||
const request = requests.begin('direct-mine');
|
||
reset ? loading.value = true : loadingMore.value = true;
|
||
if (reset) loadFailed.value = false;
|
||
try {
|
||
const result = await listMyDirectFeedback(nextPage, pageSize);
|
||
if (!requests.isCurrent(request)) return;
|
||
const rows = Array.isArray(result.rows) ? result.rows : [];
|
||
if (reset) {
|
||
items.value = rows;
|
||
} else {
|
||
const existing = new Set(items.value.map((item) => item.id));
|
||
items.value = [...items.value, ...rows.filter((item) => !existing.has(item.id))];
|
||
}
|
||
total.value = Math.max(items.value.length, Number(result.total) || 0);
|
||
pageNum.value = nextPage;
|
||
} catch {
|
||
if (requests.isCurrent(request) && reset) loadFailed.value = true;
|
||
} finally {
|
||
if (requests.isCurrent(request)) {
|
||
loading.value = false;
|
||
loadingMore.value = false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const submit = async () => {
|
||
if (!canSubmit.value || submitting.value || voiceBusy.value || !form.channelCode) return;
|
||
submitting.value = true;
|
||
submitFailed.value = false;
|
||
submitMessage.value = '';
|
||
if (!form.requestId) form.requestId = createRequestId();
|
||
try {
|
||
await submitDirectFeedback({
|
||
requestId: form.requestId,
|
||
channelCode: form.channelCode,
|
||
anonymous: form.anonymous,
|
||
content: form.content.trim()
|
||
});
|
||
form.requestId = '';
|
||
form.content = '';
|
||
voiceHint.value = '';
|
||
submitMessage.value = '已收到,当前状态为“待处理”;可在“我的反馈”查看人工正式回复。';
|
||
await loadMine(true);
|
||
} catch (error) {
|
||
submitFailed.value = true;
|
||
submitMessage.value = error instanceof Error ? error.message : '提交失败,请使用同一页面重试。';
|
||
} finally {
|
||
submitting.value = false;
|
||
}
|
||
};
|
||
|
||
onShow(async () => {
|
||
requests.activate();
|
||
resetPageScroll();
|
||
loggedIn.value = isLoggedIn();
|
||
if (!loggedIn.value) return;
|
||
try {
|
||
await Promise.all([loadChannels(), loadMine(true)]);
|
||
} catch {
|
||
loadFailed.value = true;
|
||
}
|
||
});
|
||
onHide(() => {
|
||
requests.invalidate();
|
||
if (!isPlatformNativeActivityActive()) teardownVoice();
|
||
});
|
||
onUnload(() => {
|
||
requests.invalidate();
|
||
teardownVoice();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.direct-page{max-width:var(--mobile-shell-max-width);margin:0 auto;padding-bottom:calc(32px + env(safe-area-inset-bottom))}
|
||
.login-panel,.submit-card,.loading-card,.empty-card{display:grid;gap:12px;margin-top:18px;padding:18px}
|
||
.section-head,.history-head,.feedback-head,.form-foot,.anonymous-control{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||
.section-title{display:block;color:var(--ink);font-size:18px;font-weight:900}.section-note,.muted{display:block;margin-top:4px;color:var(--muted);font-size:12px;line-height:1.6}
|
||
.channel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.channel-button{display:grid;min-height:60px;align-content:center;justify-items:center;gap:3px;padding:10px;border:1px solid #e6e8ec;border-radius:14px;background:#fff;color:var(--ink);font-size:14px;font-weight:900;text-align:center;transition:border-color .2s ease,box-shadow .2s ease,transform .1s ease}.channel-button.active{border-color:var(--brand);background:#fff5f6;color:var(--brand);box-shadow:0 6px 16px rgba(230,0,18,.10)}.channel-button:active{transform:scale(.98)}.channel-button::after,.refresh-button::after{border:0}.channel-hint{font-size:11px;font-weight:700;opacity:.72}
|
||
.channel-state{display:grid;justify-items:start;gap:10px;padding:14px;border-radius:12px;background:#f7f8fa;color:var(--muted);font-size:13px;line-height:1.5}.channel-state.error{background:#fff0f1;color:#a9000d}.channel-retry{min-width:150px;margin:0}
|
||
.channel-icon{display:grid;width:34px;height:34px;place-items:center;border-radius:12px;background:#f4f5f7;transition:background .2s ease}
|
||
.channel-button.active .channel-icon{background:var(--employee-primary-soft)}
|
||
.channel-name{line-height:1.3}
|
||
.field-head{display:flex;margin-top:4px;align-items:center;justify-content:space-between;gap:12px}.field-label{color:var(--ink);font-size:14px;font-weight:900}.feedback-input{box-sizing:border-box;width:100%;height:150px;padding:14px;border:1px solid #dfe2e7;border-radius:14px;background:#fafafa;color:var(--ink);font-size:15px;line-height:1.6;transition:border-color .2s ease,background .2s ease}.feedback-input:focus{border-color:var(--brand);background:#fff}
|
||
.voice-chip{display:inline-flex;flex-shrink:0;align-items:center;gap:5px;margin:0;padding:6px 12px;border:1px solid var(--employee-line);border-radius:999px;background:#fff;color:var(--brand);font-size:12px;font-weight:800;line-height:1.2;transition:background .2s ease,color .2s ease,opacity .2s ease}
|
||
.voice-chip::after{border:0}
|
||
.voice-chip:active{background:var(--employee-primary-soft)}
|
||
.voice-chip.recording{border-color:var(--brand);background:var(--brand);color:#fff}
|
||
.voice-chip.busy{opacity:.6}
|
||
.voice-status{display:flex;margin-top:8px;align-items:center;gap:6px;padding:8px 12px;border-radius:10px;background:#f7f8fa;color:var(--muted);font-size:12px;line-height:1.5}
|
||
.voice-status.recording{background:var(--employee-primary-soft);color:var(--brand)}
|
||
.form-foot{align-items:flex-start}.anonymous-control{justify-content:flex-start;flex:1}.anonymous-icon{display:grid;flex-shrink:0;width:32px;height:32px;place-items:center;border-radius:10px;background:#fff7e8}.anonymous-title{display:block;font-size:14px;font-weight:900}.counter{color:var(--muted);font-size:12px}.submit-button{margin-top:2px}.status-message{padding:10px 12px;border-radius:10px;background:#edf8f2;color:#147a45;font-size:13px}.status-message.error{background:#fff0f1;color:#a9000d}
|
||
.history-head{margin-top:28px}.refresh-button{padding:7px 12px;border-radius:999px;background:#fff0f1;color:var(--brand);font-size:13px;font-weight:800}.feedback-list{display:grid;gap:12px;margin-top:12px}.feedback-card{display:grid;gap:11px;padding:16px}.feedback-tags{display:flex;flex-wrap:wrap;gap:6px}.channel-tag,.anonymous-tag,.status-tag{padding:4px 8px;border-radius:999px;font-size:11px;font-weight:900}.channel-tag{background:#f2f3f5;color:#343b46}.anonymous-tag{background:#fff7e8;color:#925b00}.status-tag{background:#fff0f1;color:var(--brand)}.status-tag.replied{background:#edf8f2;color:#147a45}.feedback-content,.reply-content{white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);font-size:15px;line-height:1.7}.reply-box{display:grid;gap:6px;padding:12px;border-left:3px solid var(--brand);border-radius:4px 10px 10px 4px;background:#faf6f6}.reply-label{color:var(--brand);font-size:12px;font-weight:900}.feedback-time{color:var(--muted);font-size:12px}.load-more{width:100%}
|
||
</style>
|