feat: restore learning feedback and knowledge capabilities

This commit is contained in:
2026-07-18 00:50:11 +08:00
parent 876a23a123
commit 6bdc86e3fc
152 changed files with 30337 additions and 953 deletions
@@ -0,0 +1,145 @@
<template>
<view class="page employee-page-shell detail-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回问题榜" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">问题详情</text>
<text class="page-subtitle">回答经审核后公开,最佳答案按当前规则获得积分或学习学分。</text>
</view>
</view>
<text v-if="message" class="status-message">{{ message }}</text>
<view v-if="loading" class="panel employee-card">加载中…</view>
<template v-else-if="question">
<view class="panel employee-card question-panel">
<view class="question-meta">
<text>{{ question.category || '工作求助' }} · {{ statusLabel(question.status) }}</text>
<text v-if="question.rewardPoints > 0" class="reward">奖励 {{ question.rewardPoints }} {{ rewardUnit(question.rewardAccountType) }}</text>
</view>
<text class="question-title">{{ question.title }}</text>
<text class="question-content">{{ question.content }}</text>
<text class="author">{{ question.authorName }}{{ question.position ? ` · ${question.position}` : '' }} · {{ question.createTime }}</text>
<view v-if="bestAnswer" class="best-summary">
<text class="best-summary-title">回答已被采纳</text>
<text>最佳答案:{{ bestAnswer.authorName }} · 奖励按后台规则幂等记账</text>
</view>
</view>
<view v-if="canAnswer" class="panel employee-card answer-compose">
<text class="panel-title">写下你的经验</text>
<textarea v-model="answer" class="textarea" maxlength="1000" placeholder="说明具体做法、依据和注意事项;不要填写业主隐私。" />
<button class="primary-button" :disabled="submitting || !answer.trim()" @click="submitAnswer">
{{ submitting ? '提交中' : '提交回答' }}
</button>
<text class="muted hint">提交后进入审核;最佳答案按后台规则记入积分或学习学分账户。</text>
</view>
<view class="answers-section">
<text class="section-title">全部回答({{ question.answers?.length || 0 }})</text>
<view v-if="question.answers?.length" class="answer-list">
<view v-for="item in orderedAnswers" :key="item.id" class="answer-card employee-card" :class="{ best: item.best }">
<view class="answer-head">
<text>{{ item.authorName }}{{ item.authorPosition ? ` · ${item.authorPosition}` : '' }}</text>
<text v-if="item.best" class="best-badge">最佳答案</text>
<text v-else-if="item.recommended" class="recommend-badge">主管推荐</text>
</view>
<text class="answer-content">{{ item.content }}</text>
<text class="answer-time">{{ item.createTime }}</text>
<button
v-if="canChooseBest(item)"
class="ghost-button best-button"
:disabled="choosingBestId === item.id"
@click="chooseBest(item.id)"
>{{ choosingBestId === item.id ? '采纳中' : '采纳为最佳答案' }}</button>
</view>
</view>
<view v-else class="panel employee-card empty">还没有公开回答,来分享第一条可复用经验吧。</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import type { QuestionBoardAnswer, QuestionBoardItem } from '@/types/api';
import { answerQuestion, chooseBestAnswer, getQuestion } from '@/services/questions';
const id = ref(0);
const question = ref<QuestionBoardItem | null>(null);
const answer = ref('');
const loading = ref(false);
const submitting = ref(false);
const choosingBestId = ref(0);
const message = ref('');
const orderedAnswers = computed(() => [...(question.value?.answers || [])].sort((a, b) => Number(b.best) - Number(a.best)));
const bestAnswer = computed(() => orderedAnswers.value.find((item) => item.best));
const canAnswer = computed(() => question.value?.status === 'PUBLISHED' && !question.value.mine);
const canChooseBest = (item?: QuestionBoardAnswer) => Boolean(
item
&& question.value?.mine
&& question.value.status === 'PUBLISHED'
&& !question.value.bestAnswerId
&& !item.mine
&& item.status === 'PUBLISHED'
);
const statusLabel = (value: string) => ({ PENDING: '待审核', PUBLISHED: '讨论中', CLOSED: '已解决', REJECTED: '未通过' }[value] || value);
const rewardUnit = (accountType?: string) => accountType === 'STUDY_CREDIT' ? '学分' : '积分';
const goBack = () => uni.navigateBack({ delta: 1 });
const load = async () => {
if (!id.value) return;
loading.value = true;
try { question.value = await getQuestion(id.value); }
catch (error) { message.value = error instanceof Error ? error.message : '问题读取失败'; }
finally { loading.value = false; }
};
const submitAnswer = async () => {
if (!answer.value.trim() || submitting.value) return;
submitting.value = true;
message.value = '';
try {
await answerQuestion(id.value, answer.value.trim());
answer.value = '';
message.value = '回答已提交,审核通过后会公开';
await load();
} catch (error) { message.value = error instanceof Error ? error.message : '回答提交失败'; }
finally { submitting.value = false; }
};
const chooseBest = async (answerId: number) => {
if (!canChooseBest(orderedAnswers.value.find((item) => item.id === answerId)) || choosingBestId.value) return;
choosingBestId.value = answerId;
message.value = '';
try {
await chooseBestAnswer(id.value, answerId);
message.value = '已采纳最佳答案,奖励已按后台规则记账';
await load();
} catch (error) {
message.value = error instanceof Error ? error.message : '最佳答案设置失败';
} finally {
choosingBestId.value = 0;
}
};
onLoad((options) => { id.value = Number(options?.id || 0); });
onShow(() => { void load(); });
</script>
<style scoped>
.detail-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
.status-message { display: block; margin-top: 14px; padding: 12px; border-radius: 10px; background: #fff0f1; color: #a9000d; }
.question-panel, .answer-compose { display: grid; gap: 12px; }
.question-meta, .answer-head { display: flex; gap: 8px; align-items: center; justify-content: space-between; color: var(--knowledge); font-size: 12px; font-weight: 900; }
.reward { color: #a86100; }
.question-title { overflow-wrap: anywhere; font-size: 20px; font-weight: 900; line-height: 1.45; }
.question-content, .answer-content { overflow-wrap: anywhere; font-size: 15px; line-height: 1.75; white-space: pre-wrap; }
.author, .answer-time, .hint { color: var(--muted); font-size: 12px; line-height: 1.5; }
.answers-section { margin-top: 20px; }
.section-title { display: block; font-size: 18px; font-weight: 900; }
.answer-list { display: grid; gap: 12px; margin-top: 12px; }
.answer-card { display: grid; gap: 10px; padding: 16px; }
.answer-card.best { border: 2px solid #f0b64b; background: #fffaf0; }
.best-badge { padding: 4px 8px; border-radius: 999px; background: #f3b23c; color: #fff; }
.recommend-badge { color: var(--knowledge); }
.empty { color: var(--muted); line-height: 1.6; }
.best-summary { display:grid; gap:5px; padding:12px; border-radius:12px; background:#fff7df; color:#79500b; font-size:12px; line-height:1.55; }
.best-summary-title { font-size:15px; font-weight:900; }
.best-button { justify-self:start; min-height:40px; padding:0 14px; color:#8a5a00; }
</style>
@@ -0,0 +1,143 @@
<template>
<view class="page employee-page-shell question-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回今日" @click="goToday">←</button>
<view class="subpage-heading">
<text class="employee-kicker">广谱师傅</text>
<text class="page-title">开放问题榜</text>
<text class="page-subtitle">全公司都能回答,用真实经验帮助一线同事。</text>
</view>
</view>
<view class="hero-actions">
<button class="hero-action employee-card" @click="goPublish">
<text class="hero-action-title">今天遇到什么难题了?</text>
<text class="hero-action-copy">发布真实工作求助</text>
<text class="hero-action-link">立即发布 ›</text>
</button>
<button class="hero-action employee-card rewarded" @click="switchTab('REWARDED')">
<text class="hero-action-title">有奖征问题</text>
<text class="hero-action-copy">贡献值得讨论的好问题</text>
<text class="hero-action-link">立即查看 ›</text>
</button>
</view>
<button class="points-link" @click="goPoints">我的积分与学分 ›</button>
<view class="tab-scroll">
<view class="tab-row">
<button
v-for="item in tabs"
:key="item.key"
class="filter-chip"
:class="{ active: tab === item.key }"
:aria-pressed="tab === item.key"
@click="switchTab(item.key)"
>{{ item.label }}</button>
</view>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="loading" class="panel employee-card empty">问题加载中…</view>
<view v-else-if="!questions.length" class="panel employee-card empty">
<text class="empty-title">当前暂无问题</text>
<text class="muted">你可以发起第一条真实工作求助。</text>
</view>
<view v-else class="question-list">
<button v-for="item in questions" :key="item.id" class="question-card employee-card" @click="openQuestion(item.id)">
<view class="question-meta">
<text class="category">{{ item.category || '工作求助' }}</text>
<text v-if="item.rewardPoints > 0" class="reward">奖励 {{ item.rewardPoints }} {{ rewardUnit(item.rewardAccountType) }}</text>
</view>
<text class="question-title">{{ item.title }}</text>
<text v-if="item.content" class="question-excerpt">{{ excerpt(item.content) }}</text>
<view class="question-foot">
<text>{{ item.authorName }}{{ item.position ? ` · ${item.position}` : '' }}</text>
<text>{{ item.answerCount }} 个回答 · {{ statusLabel(item.status) }}</text>
</view>
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { QuestionBoardItem, QuestionBoardTab } from '@/types/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { listQuestions } from '@/services/questions';
import { resetPageScroll } from '@/services/navigation';
const path = '/pages/user/questions/index';
const tabs: { key: QuestionBoardTab; label: string }[] = [
{ key: 'HOT', label: '热门' },
{ key: 'UNANSWERED', label: '待回答' },
{ key: 'REWARDED', label: '有奖励' },
{ key: 'MINE', label: '我的提问' }
];
const tab = ref<QuestionBoardTab>('HOT');
const questions = ref<QuestionBoardItem[]>([]);
const loading = ref(false);
const message = ref('');
const ensureLogin = () => {
if (isLoggedIn()) return true;
rememberLoginRedirect(path);
uni.navigateTo({ url: '/pages/auth/login/index' });
return false;
};
const load = async () => {
if (!ensureLogin()) return;
loading.value = true;
message.value = '';
try {
questions.value = await listQuestions(tab.value);
} catch (error) {
questions.value = [];
message.value = error instanceof Error ? error.message : '问题榜加载失败';
} finally {
loading.value = false;
}
};
const switchTab = (value: QuestionBoardTab) => { tab.value = value; void load(); };
const excerpt = (value: string) => value.length > 92 ? `${value.slice(0, 92)}…` : value;
const rewardUnit = (accountType?: string) => accountType === 'STUDY_CREDIT' ? '学分' : '积分';
const statusLabel = (value: string) => ({
PENDING: '待审核',
PUBLISHED: '讨论中',
CLOSED: '已解决',
REJECTED: '未通过'
}[value] || value);
const goToday = () => uni.switchTab({ url: '/pages/user/today/index' });
const goPublish = () => { if (ensureLogin()) uni.navigateTo({ url: '/pages/user/questions/publish' }); };
const goPoints = () => { if (ensureLogin()) uni.navigateTo({ url: '/pages/user/points/index' }); };
const openQuestion = (id: number) => uni.navigateTo({ url: `/pages/user/questions/detail?id=${id}` });
onShow(() => { resetPageScroll(); void load(); });
</script>
<style scoped>
.question-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
.hero-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
.hero-action { display:grid; gap:7px; min-height:132px; padding:16px; color:var(--ink); text-align:left; }
.hero-action-title,.hero-action-copy,.hero-action-link { display:block; overflow-wrap:anywhere; }
.hero-action-title { color:var(--brand); font-size:16px; font-weight:900; line-height:1.45; }
.hero-action-copy { color:var(--muted); font-size:12px; line-height:1.5; }
.hero-action-link { margin-top:auto; color:var(--brand); font-size:13px; font-weight:900; }
.hero-action.rewarded .hero-action-title,.hero-action.rewarded .hero-action-link { color:#d66d00; }
.points-link { display:block; min-height:40px; margin:8px 0 0 auto; background:transparent; color:var(--knowledge); font-size:13px; font-weight:800; }
.tab-scroll { margin-top: 18px; }
.tab-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
.filter-chip { width: 100%; min-width: 0; min-height: 40px; padding: 5px 4px; border: 1px solid var(--line); border-radius: 999px; background: #fff; color: var(--muted); font-size: 13px; line-height: 1.25; }
.filter-chip.active { border-color: #f2a7ad; background: var(--employee-primary-soft); color: var(--brand); font-weight: 900; }
.status-message, .empty { display: grid; gap: 6px; margin-top: 16px; padding: 16px; }
.status-message { border-radius: 12px; background: #fff0f1; color: #a9000d; }
.empty-title { font-weight: 900; }
.question-list { display: grid; gap: 12px; margin-top: 16px; }
.question-card { display: grid; width: 100%; gap: 10px; padding: 16px; color: inherit; text-align: left; }
.question-meta, .question-foot { display: flex; gap: 8px; align-items: center; justify-content: space-between; }
.category { color: var(--knowledge); font-size: 12px; font-weight: 900; }
.reward { color: #a86100; font-size: 12px; font-weight: 900; }
.question-title { overflow-wrap: anywhere; color: var(--ink); font-size: 17px; font-weight: 900; line-height: 1.45; }
.question-excerpt { overflow-wrap: anywhere; color: var(--muted); font-size: 14px; line-height: 1.6; }
.question-foot { color: #7b8494; font-size: 12px; }
</style>
@@ -0,0 +1,61 @@
<template>
<view class="page employee-page-shell publish-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">发起求助</text>
<text class="page-subtitle">员工可发布真实工作问题;有奖征题由运营审核发布。</text>
</view>
</view>
<view class="panel employee-card form-panel">
<label class="field-label">问题标题</label>
<input v-model="title" class="input" maxlength="80" placeholder="一句话说清楚遇到的难题" />
<label class="field-label">分类</label>
<picker :range="categories" :value="categoryIndex" @change="selectCategory">
<view class="input picker-value">{{ categories[categoryIndex] }}</view>
</picker>
<label class="field-label">补充说明</label>
<textarea v-model="content" class="textarea" maxlength="1000" placeholder="写清场景、已尝试的方法和希望获得的帮助;请删除业主隐私。" />
<view class="privacy-note">提交后进入内容审核。不要填写姓名、手机号、房号、门禁、身份证或其他敏感信息。</view>
<button class="primary-button" :disabled="submitting || !valid" @click="submit">
{{ submitting ? '提交中' : '提交审核' }}
</button>
<text v-if="message" class="status-message">{{ message }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { publishQuestion } from '@/services/questions';
const categories = ['客户沟通', '工程报修', '秩序安全', '环境保洁', '收费服务', '其他'];
const categoryIndex = ref(0);
const title = ref('');
const content = ref('');
const submitting = ref(false);
const message = ref('');
const valid = computed(() => title.value.trim().length >= 6 && content.value.trim().length >= 12);
const goBack = () => uni.navigateBack({ delta: 1 });
const selectCategory = (event: { detail: { value: string } }) => { categoryIndex.value = Number(event.detail.value || 0); };
const submit = async () => {
if (!valid.value || submitting.value) return;
submitting.value = true;
message.value = '';
try {
const item = await publishQuestion({ title: title.value.trim(), content: content.value.trim(), category: categories[categoryIndex.value] });
uni.showToast({ title: '已提交审核', icon: 'none' });
setTimeout(() => uni.redirectTo({ url: `/pages/user/questions/detail?id=${item.id}` }), 350);
} catch (error) { message.value = error instanceof Error ? error.message : '提交失败'; }
finally { submitting.value = false; }
};
</script>
<style scoped>
.publish-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
.form-panel { display: grid; gap: 10px; }
.field-label { margin-top: 4px; font-size: 14px; font-weight: 900; }
.picker-value { display: flex; align-items: center; }
.privacy-note { padding: 12px; border-radius: 10px; background: #fff9e9; color: #78500a; font-size: 12px; line-height: 1.55; }
.status-message { color: #a9000d; }
</style>