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

253 lines
12 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 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)"
>
<text>{{ channel.name }}</text>
<text v-if="channel.defaultAnonymous" class="channel-hint">默认匿名</text>
</button>
</view>
<label class="field-label" for="direct-content">反馈内容</label>
<textarea
id="direct-content"
v-model="form.content"
class="feedback-input"
maxlength="5000"
placeholder="请说明具体情况、影响和希望得到的处理。"
aria-label="反馈内容"
/>
<view class="form-foot">
<label class="anonymous-control">
<switch
:checked="form.anonymous"
color="#c91825"
aria-label="匿名提交"
@change="form.anonymous = Boolean($event.detail.value)"
/>
<view>
<text class="anonymous-title">匿名提交</text>
<text class="section-note">处理端不显示提交人;系统内部仍保留审计记录。</text>
</view>
</label>
<text class="counter">{{ form.content.length }}/5000</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 || !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">
<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';
const path = '/pages/user/direct/index';
const pageSize = 20;
const loggedIn = ref(false);
const channels = ref<DirectChannel[]>([]);
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 hasMore = computed(() => items.value.length < total.value);
const canSubmit = computed(() => Boolean(form.channelCode) && Boolean(form.content.trim()));
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 loadChannels = async () => {
channels.value = await listDirectChannels();
if (!form.channelCode && channels.value.length) chooseChannel(channels.value[0]);
};
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 || !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 = '';
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());
onUnload(() => requests.invalidate());
</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;gap:3px;padding:10px;border:1px solid #e6e8ec;border-radius:14px;background:#fff;color:var(--ink);font-size:14px;font-weight:900}.channel-button.active{border-color:var(--brand);background:#fff5f6;color:var(--brand)}.channel-button::after,.refresh-button::after{border:0}.channel-hint{font-size:11px;font-weight:700;opacity:.72}
.field-label{margin-top:4px;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}
.form-foot{align-items:flex-start}.anonymous-control{justify-content:flex-start;flex:1}.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>