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
+553 -21
View File
@@ -8,6 +8,11 @@
<text class="toolbar-tip">先把问题问清楚,再按需生成总结卡,不伪造分类能力。</text>
</view>
<view class="source-switch employee-card" aria-label="问答来源">
<button class="source-option active" aria-current="page">企业问师傅</button>
<button class="source-option" @click="openWebAi">问 AI(全网)</button>
</view>
<view class="panel query-panel employee-card">
<view class="query-hero">
<view class="query-copy">
@@ -25,13 +30,47 @@
maxlength="200"
:placeholder="quickQuestionPlaceholder"
/>
<view class="voice-query-actions">
<button
class="secondary-button voice-query-button"
:class="{ 'recording-active': voiceRecording }"
:disabled="loading || voiceTranscribing"
@click="toggleVoiceRecording"
>
{{ voiceRecording ? '停止录音' : voiceTranscribing ? '转写中' : '语音提问' }}
</button>
<button
class="ghost-button voice-query-button"
:disabled="loading || voiceRecording || voiceTranscribing"
@click="pickVoiceAudio"
>
{{ voiceTranscribing ? '转写中' : '选择音频' }}
</button>
</view>
<text class="voice-query-hint">录音会先转成文字,确认问题后再提交;麦克风不可用时可选择音频。</text>
<view class="media-actions">
<button class="ghost-button media-button" :disabled="loading" @click="pickMedia">
+ 上传图片或视频
</button>
<text class="media-hint">图片≤10MB,视频≤100MB;仅用于本次现场分析</text>
</view>
<view v-if="selectedMedia" class="media-file">
<view class="media-file-copy">
<text class="media-kind">{{ selectedMedia.kind === 'video' ? '现场视频' : '现场图片' }}</text>
<text class="media-name">{{ selectedMedia.name }}</text>
</view>
<button class="media-remove" :disabled="loading" aria-label="移除附件" @click="clearMedia">×</button>
</view>
<text class="query-caption">常见问题可以直接点下面快捷问法,结果会保留引用依据。</text>
<view class="chip-row">
<button v-for="item in quickQuestions" :key="item" class="chip" :disabled="loading" @click="useQuickQuestion(item)">
{{ item }}
</button>
</view>
<button class="primary-button query-submit" :disabled="loading" @click="search">
<button class="secondary-button data-tool-button" :disabled="loading" @click="runDataTool(defaultToolCode)">
{{ defaultToolCode === 'TEAM_PRACTICE_SUMMARY' ? '团队训练概况' : '我的训练概况' }}
</button>
<button class="primary-button query-submit" :disabled="loading || voiceRecording || voiceTranscribing" @click="search">
{{ loading ? '检索中' : '问老师傅' }}
</button>
<text v-if="message" class="status-message">{{ message }}</text>
@@ -61,7 +100,12 @@
</view>
<view v-if="result" class="panel answer-panel employee-card">
<text class="panel-title">师傅回答</text>
<view class="answer-title-row">
<text class="panel-title">师傅回答</text>
<button class="voice-button" :disabled="speechState.status === 'loading' && speechState.key !== 'answer'" @click="toggleAnswerSpeech">
{{ speechButtonLabel('answer') }}
</button>
</view>
<view class="question-card">
<text class="question-label">我的问题</text>
<text class="question-text">{{ query }}</text>
@@ -69,6 +113,7 @@
<view class="answer-copy-card">
<text class="answer-text">{{ result.answer || '未生成直接答案,请查看下方引用。' }}</text>
</view>
<text v-if="result.noEvidence" class="status-message">当前授权空间内没有足够依据。</text>
<view v-if="result.keyPoints?.length" class="keypoints">
<text v-for="(point, index) in result.keyPoints" :key="point" class="keypoint">
@@ -80,7 +125,7 @@
<text v-for="item in result.cautions" :key="item">{{ item }}</text>
</view>
<view class="feedback-actions">
<view v-if="currentToolCode === ''" class="feedback-actions">
<button
class="secondary-button"
:class="{ active: feedbackVerdict === 'up' }"
@@ -107,12 +152,13 @@
<text class="panel-title">引用依据</text>
<view v-for="(snippet, index) in result.snippets" :key="`${snippet.docTitle}-${index}`" class="snippet">
<text class="snippet-title">{{ snippet.docTitle }}</text>
<text v-if="snippet.spaceCode" class="snippet-id">知识空间:{{ snippet.spaceCode }}</text>
<text class="snippet-content">{{ snippetExcerpt(snippet.content) }}</text>
<text v-if="snippet.fragmentId" class="snippet-id">片段 #{{ snippet.fragmentId }}</text>
</view>
</view>
<view v-if="result" class="panel summary-panel employee-card">
<view v-if="result && currentToolCode === ''" class="panel summary-panel employee-card">
<text class="panel-title">总结卡</text>
<text class="muted summary-copy">需要时再生成,适合把一段 SOP 整理成步骤卡、话术卡和提醒卡。</text>
<button class="ghost-button" :disabled="summaryLoading" @click="buildSummaryCard">
@@ -185,7 +231,13 @@
</view>
</scroll-view>
<view class="modal-footer">
<button class="primary-button footer-btn" @click="closeSummary">我知道了</button>
<button class="secondary-button footer-btn" :disabled="downloadingCard" @click="downloadSummary">
{{ downloadingCard ? '生成中' : '下载图片' }}
</button>
<button class="secondary-button footer-btn" @click="toggleSummarySpeech">
{{ speechButtonLabel('summary') }}
</button>
<button class="primary-button footer-btn footer-close" @click="closeSummary">我知道了</button>
</view>
</view>
</view>
@@ -194,20 +246,39 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import type {
AnswerFeedbackVerdict,
KnowledgeMediaSelectedFile,
KnowledgeSearchResponse,
SpeechPlaybackStatus,
SummaryCardResponse
} from '@/types/api';
import {
chooseKnowledgeMedia,
generateSummaryCard,
queryKnowledgeTool,
searchKnowledge,
searchKnowledgeWithMedia,
submitAnswerFeedback
} from '@/services/knowledge';
import { getSelectedPosition, getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import {
getSelectedPosition,
getSelectedPositionProfile,
isLoggedIn,
isSupervisorLearnerMode,
rememberLoginRedirect,
roleKey
} from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
import {
chooseSpeechAudio,
createSpeechPlaybackController,
transcribeSpeechBlob,
transcribeSpeechFile
} from '@/services/speech';
import { downloadSummaryCardImage } from '@/services/summary-card-image';
const positionProfile = ref(getSelectedPositionProfile());
const positionLabel = ref(getSelectedPosition());
@@ -218,23 +289,62 @@ const result = ref<KnowledgeSearchResponse | null>(null);
const summaryCard = ref<SummaryCardResponse | null>(null);
const loading = ref(false);
const summaryLoading = ref(false);
const downloadingCard = ref(false);
const selectedMedia = ref<KnowledgeMediaSelectedFile | null>(null);
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
const voiceRecording = ref(false);
const voiceTranscribing = ref(false);
const voiceRecorder = ref<MediaRecorder | null>(null);
const voiceStream = ref<MediaStream | null>(null);
const voiceStarting = ref(false);
const feedbackSubmitting = ref(false);
const feedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
const message = ref('');
const loggedIn = ref(false);
const pendingSopQueryKey = 'aihr_sop_pending_query';
const pendingToolKey = 'aihr_sop_pending_tool';
const currentToolCode = ref<'' | 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'>('');
const currentRole = ref(String(uni.getStorageSync(roleKey) || ''));
const supervisorLearner = ref(isSupervisorLearnerMode());
let voiceCaptureGeneration = 0;
let voiceTranscriptionGeneration = 0;
let abortVoiceTranscription: (() => void) | null = null;
let knowledgeSearchGeneration = 0;
let activeMediaUploadTask: ReturnType<typeof uni.uploadFile> | null = null;
const defaultToolCode = computed<'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY'>(() =>
currentRole.value === 'supervisor' && !supervisorLearner.value
? 'TEAM_PRACTICE_SUMMARY'
: 'MY_PRACTICE_SUMMARY'
);
const speechPlayback = createSpeechPlaybackController(
(state) => { speechState.value = state; },
(text) => {
message.value = text;
uni.showToast({ title: text, icon: 'none' });
}
);
const resetResultState = () => {
result.value = null;
summaryCard.value = null;
feedbackVerdict.value = '';
feedbackSubmitting.value = false;
currentToolCode.value = '';
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
positionLabel.value = getSelectedPosition();
positionProfile.value = getSelectedPositionProfile();
currentRole.value = String(uni.getStorageSync(roleKey) || '');
supervisorLearner.value = isSupervisorLearnerMode();
};
const cancelPendingMediaSearch = () => {
knowledgeSearchGeneration += 1;
activeMediaUploadTask?.abort();
activeMediaUploadTask = null;
loading.value = false;
};
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
@@ -249,12 +359,13 @@ const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = logg
};
const search = async () => {
if (loading.value) return;
const value = query.value.trim();
if (loading.value || voiceRecording.value || voiceTranscribing.value) return;
const value = query.value.trim() || (selectedMedia.value ? '请根据附件现场情况,告诉我应该按什么 SOP 处理' : '');
if (!value) {
message.value = '请先输入问题';
return;
}
query.value = value;
refreshAuthState();
if (!loggedIn.value) {
uni.setStorageSync(pendingSopQueryKey, value);
@@ -267,25 +378,224 @@ const search = async () => {
positionProfile.value = getSelectedPositionProfile();
positionLabel.value = getSelectedPosition();
loading.value = true;
const generation = ++knowledgeSearchGeneration;
message.value = '';
resetResultState();
try {
result.value = await searchKnowledge(value);
const nextResult = selectedMedia.value
? await searchKnowledgeWithMedia(selectedMedia.value, value, getSelectedPosition(), (task) => {
if (generation === knowledgeSearchGeneration) activeMediaUploadTask = task;
})
: await searchKnowledge(value);
if (generation !== knowledgeSearchGeneration) return;
result.value = nextResult;
if (!result.value.answer && !result.value.snippets?.length) {
message.value = '未检索到可引用内容';
}
} catch (error) {
if (generation !== knowledgeSearchGeneration) return;
handleRequestError(error, '检索失败', true);
} finally {
loading.value = false;
if (generation === knowledgeSearchGeneration) {
activeMediaUploadTask = null;
loading.value = false;
}
}
};
const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY') => {
if (loading.value) return;
refreshAuthState();
if (!loggedIn.value) {
uni.setStorageSync(pendingToolKey, toolCode);
goLogin();
return;
}
const effectiveToolCode = supervisorLearner.value && toolCode === 'TEAM_PRACTICE_SUMMARY'
? 'MY_PRACTICE_SUMMARY'
: toolCode;
const generation = ++knowledgeSearchGeneration;
loading.value = true;
message.value = '';
resetResultState();
selectedMedia.value = null;
currentToolCode.value = effectiveToolCode;
const question = effectiveToolCode === 'TEAM_PRACTICE_SUMMARY' ? '查询我的团队训练概况' : '查询我的训练概况';
query.value = question;
try {
const nextResult = await queryKnowledgeTool(effectiveToolCode, question);
if (generation !== knowledgeSearchGeneration) return;
result.value = nextResult;
} catch (error) {
if (generation !== knowledgeSearchGeneration) return;
handleRequestError(error, '训练概况查询失败', true);
} finally {
if (generation === knowledgeSearchGeneration) loading.value = false;
}
};
const useQuickQuestion = (value: string) => {
selectedMedia.value = null;
query.value = value;
void search();
};
const pickMedia = async () => {
if (loading.value) return;
try {
selectedMedia.value = await chooseKnowledgeMedia();
message.value = selectedMedia.value.kind === 'video'
? '已选择视频,提交后会提取语音、画面文字和场景信息'
: '已选择图片,可补充一句现场问题后提交';
} catch (error) {
const text = error instanceof Error ? error.message : '附件选择失败';
if (text !== '未选择附件') message.value = text;
}
};
const clearMedia = () => {
if (loading.value) return;
selectedMedia.value = null;
};
const stopVoiceTracks = () => {
voiceStream.value?.getTracks().forEach((track) => track.stop());
voiceStream.value = null;
};
const cancelVoiceTranscription = () => {
voiceTranscriptionGeneration += 1;
abortVoiceTranscription?.();
abortVoiceTranscription = null;
voiceTranscribing.value = false;
};
const cancelVoiceRecording = () => {
voiceCaptureGeneration += 1;
cancelVoiceTranscription();
voiceStarting.value = false;
const recorder = voiceRecorder.value;
if (recorder && recorder.state !== 'inactive') {
recorder.ondataavailable = null;
recorder.onstop = null;
recorder.stop();
}
voiceRecorder.value = null;
stopVoiceTracks();
voiceRecording.value = false;
};
const applyVoiceTranscription = (text?: string) => {
const value = (text || '').trim();
if (!value) throw new Error('语音没有识别到文字');
const current = query.value.trim();
query.value = current ? `${current}\n${value}` : value;
message.value = '语音已转成文字,请确认问题后提交';
};
const transcribeVoiceBlob = async (blob: Blob) => {
if (voiceTranscribing.value) return;
const generation = ++voiceTranscriptionGeneration;
voiceTranscribing.value = true;
message.value = '语音转写中';
try {
const result = await transcribeSpeechBlob(blob, 'sop-question.webm', (abort) => {
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
});
if (generation !== voiceTranscriptionGeneration) return;
applyVoiceTranscription(result.text);
} catch (error) {
if (generation !== voiceTranscriptionGeneration) return;
message.value = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
} finally {
if (generation === voiceTranscriptionGeneration) {
abortVoiceTranscription = null;
voiceTranscribing.value = false;
}
}
};
const pickVoiceAudio = async () => {
if (loading.value || voiceRecording.value || voiceTranscribing.value) return;
const generation = ++voiceTranscriptionGeneration;
voiceTranscribing.value = true;
message.value = '语音转写中';
try {
const file = await chooseSpeechAudio();
if (generation !== voiceTranscriptionGeneration) return;
const result = await transcribeSpeechFile(file, (abort) => {
if (generation === voiceTranscriptionGeneration) abortVoiceTranscription = abort;
});
if (generation !== voiceTranscriptionGeneration) return;
applyVoiceTranscription(result.text);
} catch (error) {
if (generation !== voiceTranscriptionGeneration) return;
const text = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
if (text !== '未选择音频') message.value = text;
} finally {
if (generation === voiceTranscriptionGeneration) {
abortVoiceTranscription = null;
voiceTranscribing.value = false;
}
}
};
const startVoiceRecording = async () => {
if (loading.value || voiceStarting.value || voiceRecording.value || voiceTranscribing.value) return;
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
await pickVoiceAudio();
return;
}
const generation = ++voiceCaptureGeneration;
voiceStarting.value = true;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (generation !== voiceCaptureGeneration) {
stream.getTracks().forEach((track) => track.stop());
return;
}
voiceStream.value = stream;
const recorder = new MediaRecorder(stream);
const chunks: Blob[] = [];
voiceRecorder.value = recorder;
recorder.ondataavailable = (event) => {
if (event.data?.size) chunks.push(event.data);
};
recorder.onstop = () => {
voiceRecording.value = false;
voiceRecorder.value = null;
stopVoiceTracks();
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
if (!blob.size) {
message.value = '未录到音频,请改用文字输入';
return;
}
void transcribeVoiceBlob(blob);
};
recorder.start();
voiceRecording.value = true;
message.value = '正在录音,再点一次结束';
} catch {
stopVoiceTracks();
if (generation === voiceCaptureGeneration) await pickVoiceAudio();
} finally {
if (generation === voiceCaptureGeneration) voiceStarting.value = false;
}
};
const toggleVoiceRecording = () => {
if (voiceStarting.value) return;
if (voiceRecording.value) {
const recorder = voiceRecorder.value;
if (recorder && recorder.state !== 'inactive') {
message.value = '录音结束,正在转写';
recorder.stop();
}
return;
}
void startVoiceRecording();
};
const snippetExcerpt = (text = '') => {
const value = text.trim();
return value.length > 260 ? `${value.slice(0, 260)}...` : value;
@@ -350,14 +660,67 @@ const buildSummaryCard = async () => {
};
const closeSummary = () => {
if (speechState.value.key === 'summary') speechPlayback.stop();
summaryCard.value = null;
};
const speechButtonLabel = (key: string) => {
if (speechState.value.key !== key) return '语音播报';
if (speechState.value.status === 'loading') return '生成语音中';
if (speechState.value.status === 'playing') return '停止播报';
return '语音播报';
};
const answerSpeechText = () => {
if (!result.value) return '';
return [
result.value.answer,
...(result.value.keyPoints || []).map((item, index) => `第${index + 1}点,${cleanOrderedText(item)}`),
...(result.value.cautions || []).map((item) => `注意,${item}`)
].filter(Boolean).join('。');
};
const summarySpeechText = () => {
if (!summaryCard.value) return '';
return [
summaryCard.value.title,
...(summaryCard.value.steps || []).map((item, index) => `第${index + 1}步,${item.label},${item.desc}`),
...(summaryCard.value.reminders || []).map((item) => `师傅提醒,${item}`)
].filter(Boolean).join('。');
};
const toggleAnswerSpeech = () => {
void speechPlayback.toggle('answer', answerSpeechText());
};
const toggleSummarySpeech = () => {
void speechPlayback.toggle('summary', summarySpeechText());
};
const downloadSummary = async () => {
if (!summaryCard.value || downloadingCard.value) return;
downloadingCard.value = true;
try {
await downloadSummaryCardImage(summaryCard.value);
uni.showToast({ title: '总结卡图片已下载', icon: 'none' });
} catch (error) {
const text = error instanceof Error ? error.message : '总结卡下载失败';
message.value = text;
uni.showToast({ title: text, icon: 'none' });
} finally {
downloadingCard.value = false;
}
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/sop/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const openWebAi = () => {
uni.navigateTo({ url: '/pages/user/web-ai/index' });
};
const handleShow = async () => {
resetPageScroll();
refreshAuthState();
@@ -369,6 +732,17 @@ const handleShow = async () => {
resetResultState();
}
const pending = uni.getStorageSync(pendingSopQueryKey);
const pendingTool = uni.getStorageSync(pendingToolKey);
if (pendingTool === 'MY_PRACTICE_SUMMARY' || pendingTool === 'TEAM_PRACTICE_SUMMARY') {
if (!loggedIn.value) {
message.value = '登录后继续查询训练概况';
goLogin();
return;
}
uni.removeStorageSync(pendingToolKey);
void runDataTool(pendingTool);
return;
}
if (!pending) {
if (loggedIn.value && query.value.trim() && message.value === '登录后继续查询该问题') {
void search();
@@ -387,6 +761,17 @@ const handleShow = async () => {
onShow(() => {
void handleShow();
});
onHide(() => {
speechPlayback.stop();
cancelVoiceRecording();
cancelPendingMediaSearch();
});
onUnload(() => {
speechPlayback.destroy();
cancelVoiceRecording();
});
</script>
<style scoped>
@@ -414,6 +799,26 @@ onShow(() => {
content: '';
}
.source-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-top: 16px;
padding: 4px;
}
.source-option {
min-height: 44px;
background: transparent;
color: var(--employee-muted);
font-weight: 800;
}
.source-option.active {
background: var(--employee-primary-soft);
color: var(--employee-primary);
}
.sop-eyebrow {
display: inline-flex;
margin-bottom: 12px;
@@ -798,7 +1203,7 @@ onShow(() => {
background: #168243;
color: #ffffff;
border-radius: 50%;
font-size: 11px;
font-size: 12px;
font-weight: 800;
display: flex;
align-items: center;
@@ -814,13 +1219,13 @@ onShow(() => {
/* Steps Timeline */
.timeline {
position: relative;
padding-left: 14px;
padding-left: 18px;
}
.timeline::before {
content: '';
position: absolute;
left: 5px;
left: 8px;
top: 6px;
bottom: 6px;
width: 2px;
@@ -840,16 +1245,16 @@ onShow(() => {
.timeline-node {
position: absolute;
left: -14px;
width: 12px;
height: 12px;
left: -18px;
width: 18px;
height: 18px;
background: #168243;
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
font-size: 12px;
font-weight: 800;
}
@@ -921,9 +1326,9 @@ onShow(() => {
.q-badge,
.a-badge {
flex: 0 0 18px;
height: 18px;
font-size: 10px;
flex: 0 0 22px;
height: 22px;
font-size: 12px;
font-weight: 800;
border-radius: 4px;
display: flex;
@@ -1283,4 +1688,131 @@ onShow(() => {
.query-panel .query-caption {
display: none;
}
.media-actions {
display: flex;
align-items: center;
gap: 10px;
}
.voice-query-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.voice-query-button {
width: 100%;
min-height: 44px;
margin: 0;
}
.voice-query-button.recording-active {
border-color: var(--employee-primary);
background: #fff0f1;
color: var(--employee-primary);
}
.voice-query-hint {
display: block;
margin-top: -2px;
color: var(--employee-muted);
font-size: 12px;
line-height: 1.5;
}
.media-button {
min-height: 42px;
margin: 0;
padding: 0 14px;
border-color: #cfe0ff;
color: var(--employee-primary);
font-size: 13px;
}
.media-hint {
flex: 1;
color: var(--employee-muted);
font-size: 12px;
line-height: 1.45;
}
.media-file {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid #cfe0ff;
border-radius: 14px;
background: var(--employee-primary-soft);
}
.media-file-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 4px;
}
.media-kind {
color: var(--employee-primary);
font-size: 12px;
font-weight: 800;
}
.media-name {
overflow: hidden;
color: var(--employee-text);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-remove {
width: 32px;
min-width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
border-radius: 50%;
background: #ffffff;
color: var(--employee-muted);
font-size: 21px;
line-height: 32px;
}
.answer-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.voice-button {
min-height: 38px;
padding: 0 12px;
border: 1px solid #f3b7bd;
border-radius: 999px;
background: #fff7f8;
color: var(--employee-primary);
font-size: 12px;
font-weight: 800;
}
.modal-footer {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.modal-footer .footer-btn {
width: 100%;
margin: 0;
}
.modal-footer .footer-close {
grid-column: span 2;
}
</style>