Files
prop-ai-hr/mobile-uni/src/pages/user/direct/index.vue
T

518 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>