Compare commits
3 Commits
88c479f730
...
6cfb3b2f93
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cfb3b2f93 | |||
| bf80cf15b6 | |||
| fbf134ccc6 |
@ -1,21 +1,59 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
withDefaults(
|
import { useRouter } from 'vue-router'
|
||||||
|
import AppIcon from './AppIcon.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title: string
|
title?: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
|
/** page:一级页头(h1+副标题+右段动作);detail:详情/子页返回头(紧凑标题,默认带返回) */
|
||||||
|
size?: 'page' | 'detail'
|
||||||
|
/** 是否渲染左段返回按钮 */
|
||||||
|
back?: boolean
|
||||||
|
/** 返回目标;缺省时使用 history.back() */
|
||||||
|
backTo?: string
|
||||||
/** 移动端是否纵向堆叠(部分页面移动版按钮在标题下方) */
|
/** 移动端是否纵向堆叠(部分页面移动版按钮在标题下方) */
|
||||||
stackMobile?: boolean
|
stackMobile?: boolean
|
||||||
|
/** 移动端吸顶(替代原 layout topbar 的吸顶行为;仅移动端生效) */
|
||||||
|
stickyMobile?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{ subtitle: '', stackMobile: false }
|
{ title: '', subtitle: '', size: 'page', back: false, backTo: '', stackMobile: false, stickyMobile: false }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (props.backTo) router.push(props.backTo)
|
||||||
|
else router.back()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="page-head" :class="{ 'page-head--stack': stackMobile }">
|
<header
|
||||||
|
class="page-head"
|
||||||
|
:class="{
|
||||||
|
'page-head--stack': stackMobile,
|
||||||
|
'page-head--detail': size === 'detail',
|
||||||
|
'page-head--sticky-mobile': stickyMobile
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="back"
|
||||||
|
type="button"
|
||||||
|
class="page-head__back"
|
||||||
|
aria-label="返回"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
<AppIcon name="chevron-left" :size="20" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="page-head__title">
|
<div class="page-head__title">
|
||||||
<h1>{{ title }}</h1>
|
<slot name="title">
|
||||||
<p v-if="subtitle">{{ subtitle }}</p>
|
<h1 v-if="title">{{ title }}</h1>
|
||||||
|
<p v-if="subtitle">{{ subtitle }}</p>
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="$slots.default" class="page-head__actions"><slot /></div>
|
<div v-if="$slots.default" class="page-head__actions"><slot /></div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@ -49,9 +87,53 @@ withDefaults(
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 返回按钮(左段) */
|
||||||
|
.page-head__back {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--motion-hover-fast) var(--ease-default);
|
||||||
|
}
|
||||||
|
.page-head__back:hover {
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 详情/子页返回头:紧凑行内布局 */
|
||||||
|
.page-head--detail {
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.page-head--detail .page-head__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.page-head--detail h1 {
|
||||||
|
font-size: var(--fs-h3);
|
||||||
|
font-weight: var(--fw-h1);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.page-head--stack {
|
.page-head--stack {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
/* 吸顶(替代原 layout topbar;负边距让背景铺满屏宽) */
|
||||||
|
.page-head--sticky-mobile {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 30;
|
||||||
|
margin-left: calc(-1 * var(--padding-mobile));
|
||||||
|
margin-right: calc(-1 * var(--padding-mobile));
|
||||||
|
padding: var(--space-2) var(--padding-mobile);
|
||||||
|
background: var(--bg-canvas);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
93
client/src/components/base/AppStack.vue
Normal file
93
client/src/components/base/AppStack.vue
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 主轴方向;默认纵向 */
|
||||||
|
direction?: 'row' | 'column'
|
||||||
|
/**
|
||||||
|
* 子项间距。兼容三种写法:
|
||||||
|
* - token 名:space-3 / space-4 / space-5 …
|
||||||
|
* - 纯数字字符串:'12' → 12px
|
||||||
|
* - 其它 CSS 值:'12px 16px'、'var(--space-3)' 等原样透传
|
||||||
|
* 缺省:纵向 space-5(20px),横向 space-4(16px)
|
||||||
|
*/
|
||||||
|
gap?: string
|
||||||
|
/** 交叉轴对齐 */
|
||||||
|
align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline'
|
||||||
|
/** 主轴对齐 */
|
||||||
|
justify?: 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly'
|
||||||
|
/** 是否允许换行(横向时使用) */
|
||||||
|
wrap?: boolean
|
||||||
|
}>(),
|
||||||
|
{ direction: 'column', gap: '', align: 'stretch', justify: 'start', wrap: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
/** token 名 / 纯数字 → 兼容的 CSS gap;其它原样透传 */
|
||||||
|
const gapStyle = computed(() => {
|
||||||
|
const raw = props.gap.trim()
|
||||||
|
if (!raw) return props.direction === 'row' ? 'var(--space-4)' : 'var(--space-5)'
|
||||||
|
if (/^space-[1-8]$/.test(raw)) return `var(--${raw})`
|
||||||
|
if (/^\d+$/.test(raw)) return `${raw}px`
|
||||||
|
return raw
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="app-stack"
|
||||||
|
:class="{
|
||||||
|
'app-stack--row': direction === 'row',
|
||||||
|
'app-stack--wrap': wrap,
|
||||||
|
[`app-stack--align-${align}`]: align !== 'stretch',
|
||||||
|
[`app-stack--justify-${justify}`]: justify !== 'start'
|
||||||
|
}"
|
||||||
|
:style="{ '--stack-gap': gapStyle }"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: var(--stack-gap, var(--space-5));
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.app-stack--row {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.app-stack--wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.app-stack--align-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.app-stack--align-start {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.app-stack--align-end {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.app-stack--align-baseline {
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.app-stack--justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.app-stack--justify-end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.app-stack--justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.app-stack--justify-around {
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
.app-stack--justify-evenly {
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,22 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import AppIcon from '../components/base/AppIcon.vue'
|
import AppIcon from '../components/base/AppIcon.vue'
|
||||||
import { mobileNav } from '../router/nav'
|
import { mobileNav } from '../router/nav'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const title = computed(() => String(route.meta.title ?? '备考通'))
|
|
||||||
// 首页以自己的问候语作为页头,不再重复显示顶部栏标题
|
|
||||||
const isHome = computed(() => route.path === '/')
|
|
||||||
// 刷题相关页面自带页头(AppPageHeader),隐藏顶栏标题避免重复
|
|
||||||
const hideTopbar = computed(() => isHome.value || route.path.startsWith('/practice'))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="layout-mobile">
|
<div class="layout-mobile">
|
||||||
<header v-if="!hideTopbar" class="topbar">
|
|
||||||
<h1>{{ title }}</h1>
|
|
||||||
</header>
|
|
||||||
<main class="content"><slot /></main>
|
<main class="content"><slot /></main>
|
||||||
<nav class="tabbar">
|
<nav class="tabbar">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
@ -37,24 +28,8 @@ const hideTopbar = computed(() => isHome.value || route.path.startsWith('/practi
|
|||||||
.layout-mobile {
|
.layout-mobile {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
.topbar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 56px;
|
|
||||||
padding: 0 var(--padding-mobile);
|
|
||||||
background: var(--bg-canvas);
|
|
||||||
}
|
|
||||||
.topbar h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: var(--fw-h1);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.content {
|
.content {
|
||||||
padding: 0 var(--padding-mobile) var(--tabbar-height-mobile);
|
padding: var(--padding-mobile) var(--padding-mobile) var(--tabbar-height-mobile);
|
||||||
}
|
}
|
||||||
.tabbar {
|
.tabbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@ -130,7 +130,7 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mock-form">
|
<div class="mock-form">
|
||||||
<AppPageHeader :title="title" subtitle="仅支持行测五个模块分数,总分自动汇总">
|
<AppPageHeader :title="title" subtitle="仅支持行测五个模块分数,总分自动汇总" sticky-mobile>
|
||||||
<AppButton variant="ghost" @click="back">
|
<AppButton variant="ghost" @click="back">
|
||||||
<AppIcon name="chevron-left" :size="16" />返回模考分析
|
<AppIcon name="chevron-left" :size="16" />返回模考分析
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import AppLoading from '../../components/feedback/AppLoading.vue'
|
|||||||
import AppError from '../../components/feedback/AppError.vue'
|
import AppError from '../../components/feedback/AppError.vue'
|
||||||
import AppEmpty from '../../components/feedback/AppEmpty.vue'
|
import AppEmpty from '../../components/feedback/AppEmpty.vue'
|
||||||
import StatCard from '../../components/dashboard/StatCard.vue'
|
import StatCard from '../../components/dashboard/StatCard.vue'
|
||||||
|
import AppStack from '../../components/base/AppStack.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isMobile } = useResponsive()
|
const { isMobile } = useResponsive()
|
||||||
@ -86,7 +87,7 @@ async function confirmRemove() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mock-page">
|
<AppStack class="mock-page">
|
||||||
<AppLoading v-if="analysis.loading.value && !hasData" fullscreen />
|
<AppLoading v-if="analysis.loading.value && !hasData" fullscreen />
|
||||||
<AppError
|
<AppError
|
||||||
v-else-if="analysis.error.value"
|
v-else-if="analysis.error.value"
|
||||||
@ -98,7 +99,7 @@ async function confirmRemove() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<AppPageHeader title="模考分析" subtitle="行测成绩趋势 · 目标分差 · 模块对比" :stack-mobile="isMobile">
|
<AppPageHeader title="模考分析" subtitle="行测成绩趋势 · 目标分差 · 模块对比" :stack-mobile="isMobile" sticky-mobile>
|
||||||
<AppButton variant="primary" @click="goEntry">
|
<AppButton variant="primary" @click="goEntry">
|
||||||
<AppIcon name="plus" :size="16" />录入成绩
|
<AppIcon name="plus" :size="16" />录入成绩
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@ -213,15 +214,10 @@ async function confirmRemove() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</AppModal>
|
</AppModal>
|
||||||
</div>
|
</AppStack>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.mock-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-5);
|
|
||||||
}
|
|
||||||
.mock-page__stats {
|
.mock-page__stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
|||||||
@ -8,8 +8,7 @@ import { formatDateShort } from '../../utils/format'
|
|||||||
import AppLoading from '../../components/feedback/AppLoading.vue'
|
import AppLoading from '../../components/feedback/AppLoading.vue'
|
||||||
import AppError from '../../components/feedback/AppError.vue'
|
import AppError from '../../components/feedback/AppError.vue'
|
||||||
import AppEmpty from '../../components/feedback/AppEmpty.vue'
|
import AppEmpty from '../../components/feedback/AppEmpty.vue'
|
||||||
import AppIcon from '../../components/base/AppIcon.vue'
|
import AppPageHeader from '../../components/base/AppPageHeader.vue'
|
||||||
import AppButton from '../../components/base/AppButton.vue'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const id = computed(() => String(route.params.id ?? ''))
|
const id = computed(() => String(route.params.id ?? ''))
|
||||||
@ -32,12 +31,9 @@ const html = computed(() => renderMarkdown(detail.data.value?.content ?? ''))
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-else-if="detail.data.value">
|
<template v-else-if="detail.data.value">
|
||||||
<header class="head">
|
<AppPageHeader back class="news-detail__head" sticky-mobile>
|
||||||
<AppButton class="head__back" variant="ghost" size="md" aria-label="返回" @click="$router.back()">
|
<template #title><span class="head__label">要闻详情</span></template>
|
||||||
<AppIcon name="chevron-left" :size="20" />
|
</AppPageHeader>
|
||||||
</AppButton>
|
|
||||||
<span class="head__label">要闻详情</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<article class="article">
|
<article class="article">
|
||||||
<section class="article__hero">
|
<section class="article__hero">
|
||||||
@ -61,25 +57,9 @@ const html = computed(() => renderMarkdown(detail.data.value?.content ?? ''))
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.head {
|
.news-detail__head {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
.head__back {
|
|
||||||
display: grid !important;
|
|
||||||
place-items: center;
|
|
||||||
width: 36px !important;
|
|
||||||
height: 36px !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
border: none !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
background: var(--bg-card) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
gap: 0 !important;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.head__label {
|
.head__label {
|
||||||
font-size: var(--fs-subtitle);
|
font-size: var(--fs-subtitle);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import AppButton from '../../components/base/AppButton.vue'
|
|||||||
import AppIcon from '../../components/base/AppIcon.vue'
|
import AppIcon from '../../components/base/AppIcon.vue'
|
||||||
import AppModal from '../../components/base/AppModal.vue'
|
import AppModal from '../../components/base/AppModal.vue'
|
||||||
import AppPageHeader from '../../components/base/AppPageHeader.vue'
|
import AppPageHeader from '../../components/base/AppPageHeader.vue'
|
||||||
|
import AppStack from '../../components/base/AppStack.vue'
|
||||||
import AppLoading from '../../components/feedback/AppLoading.vue'
|
import AppLoading from '../../components/feedback/AppLoading.vue'
|
||||||
import AppError from '../../components/feedback/AppError.vue'
|
import AppError from '../../components/feedback/AppError.vue'
|
||||||
import AppEmpty from '../../components/feedback/AppEmpty.vue'
|
import AppEmpty from '../../components/feedback/AppEmpty.vue'
|
||||||
@ -83,7 +84,7 @@ async function doImport() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="news">
|
<AppStack class="news" gap="space-4">
|
||||||
<AppLoading v-if="list.loading.value" fullscreen />
|
<AppLoading v-if="list.loading.value" fullscreen />
|
||||||
<AppError
|
<AppError
|
||||||
v-else-if="list.error.value"
|
v-else-if="list.error.value"
|
||||||
@ -96,7 +97,7 @@ async function doImport() {
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- 页头 -->
|
<!-- 页头 -->
|
||||||
<AppPageHeader :title="isMobile ? '要闻公告' : '要闻'" subtitle="最新招考资讯与政策动态">
|
<AppPageHeader :title="isMobile ? '要闻公告' : '要闻'" subtitle="最新招考资讯与政策动态" sticky-mobile>
|
||||||
<AppButton variant="ghost" @click="openImport">订阅推送</AppButton>
|
<AppButton variant="ghost" @click="openImport">订阅推送</AppButton>
|
||||||
<AppButton variant="primary" @click="openImport">
|
<AppButton variant="primary" @click="openImport">
|
||||||
<AppIcon name="inbox" :size="16" />发布公告
|
<AppIcon name="inbox" :size="16" />发布公告
|
||||||
@ -197,16 +198,10 @@ async function doImport() {
|
|||||||
<AppButton variant="primary" @click="doImport">开始导入</AppButton>
|
<AppButton variant="primary" @click="doImport">开始导入</AppButton>
|
||||||
</template>
|
</template>
|
||||||
</AppModal>
|
</AppModal>
|
||||||
</div>
|
</AppStack>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.news {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 分类 tab */
|
/* 分类 tab */
|
||||||
.news-tabs {
|
.news-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -4,8 +4,7 @@ import { plansApi } from '../../api'
|
|||||||
import { useRequest } from '../../composables/useRequest'
|
import { useRequest } from '../../composables/useRequest'
|
||||||
import { formatDateShort, formatMinutes, weekdayName } from '../../utils/format'
|
import { formatDateShort, formatMinutes, weekdayName } from '../../utils/format'
|
||||||
import AppCard from '../../components/base/AppCard.vue'
|
import AppCard from '../../components/base/AppCard.vue'
|
||||||
import AppIcon from '../../components/base/AppIcon.vue'
|
import AppPageHeader from '../../components/base/AppPageHeader.vue'
|
||||||
import AppButton from '../../components/base/AppButton.vue'
|
|
||||||
import AppLoading from '../../components/feedback/AppLoading.vue'
|
import AppLoading from '../../components/feedback/AppLoading.vue'
|
||||||
import AppError from '../../components/feedback/AppError.vue'
|
import AppError from '../../components/feedback/AppError.vue'
|
||||||
import AppEmpty from '../../components/feedback/AppEmpty.vue'
|
import AppEmpty from '../../components/feedback/AppEmpty.vue'
|
||||||
@ -45,13 +44,10 @@ const weakTone = ['danger', 'warning', 'primary']
|
|||||||
|
|
||||||
<template v-else-if="data">
|
<template v-else-if="data">
|
||||||
<!-- 页头 -->
|
<!-- 页头 -->
|
||||||
<header class="review__head">
|
<AppPageHeader back sticky-mobile>
|
||||||
<AppButton class="review__back" variant="ghost" size="md" aria-label="返回" @click="$router.back()">
|
<template #title><h1 class="review__title">周报复盘</h1></template>
|
||||||
<AppIcon name="chevron-left" :size="20" />
|
|
||||||
</AppButton>
|
|
||||||
<h1>周报复盘</h1>
|
|
||||||
<span class="review__export">本周小结</span>
|
<span class="review__export">本周小结</span>
|
||||||
</header>
|
</AppPageHeader>
|
||||||
|
|
||||||
<!-- 周区间 + 汇总 -->
|
<!-- 周区间 + 汇总 -->
|
||||||
<section class="review-hero">
|
<section class="review-hero">
|
||||||
@ -120,32 +116,13 @@ const weakTone = ['danger', 'warning', 'primary']
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
}
|
}
|
||||||
.review__head {
|
.review__title {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.review__back {
|
|
||||||
display: grid !important;
|
|
||||||
place-items: center;
|
|
||||||
width: 36px !important;
|
|
||||||
height: 36px !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
border: none !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
background: var(--bg-card) !important;
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
gap: 0 !important;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.review__head h1 {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--fs-h1);
|
font-size: var(--fs-h1);
|
||||||
font-weight: var(--fw-h1);
|
font-weight: var(--fw-h1);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.review__export {
|
.review__export {
|
||||||
margin-left: auto;
|
|
||||||
font-size: var(--fs-label);
|
font-size: var(--fs-label);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -234,7 +234,7 @@ const profileDays = computed(() => (examProfile.data.value ? daysSince(examProfi
|
|||||||
|
|
||||||
<!-- ============ 桌面端备考计划 ============ -->
|
<!-- ============ 桌面端备考计划 ============ -->
|
||||||
<div v-else-if="todayData" class="plan-desktop">
|
<div v-else-if="todayData" class="plan-desktop">
|
||||||
<AppPageHeader title="备考计划" :subtitle="`安排阶段任务,稳步推进备考进度 · 备考第 ${profileDays} 天`">
|
<AppPageHeader title="备考计划" :subtitle="`安排阶段任务,稳步推进备考进度 · 备考第 ${profileDays} 天`" sticky-mobile>
|
||||||
<RouterLink class="btn-link" to="/plan/review">
|
<RouterLink class="btn-link" to="/plan/review">
|
||||||
<AppIcon name="chart" :size="16" />周报复盘
|
<AppIcon name="chart" :size="16" />周报复盘
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import AppCard from '../../components/base/AppCard.vue'
|
|||||||
import AppButton from '../../components/base/AppButton.vue'
|
import AppButton from '../../components/base/AppButton.vue'
|
||||||
import AppIcon from '../../components/base/AppIcon.vue'
|
import AppIcon from '../../components/base/AppIcon.vue'
|
||||||
import AppBadge from '../../components/base/AppBadge.vue'
|
import AppBadge from '../../components/base/AppBadge.vue'
|
||||||
|
import AppPageHeader from '../../components/base/AppPageHeader.vue'
|
||||||
import AppLoading from '../../components/feedback/AppLoading.vue'
|
import AppLoading from '../../components/feedback/AppLoading.vue'
|
||||||
import AppError from '../../components/feedback/AppError.vue'
|
import AppError from '../../components/feedback/AppError.vue'
|
||||||
|
|
||||||
@ -68,15 +69,11 @@ const mistakeText = computed(() => {
|
|||||||
|
|
||||||
<template v-else-if="explain">
|
<template v-else-if="explain">
|
||||||
<!-- 顶栏 -->
|
<!-- 顶栏 -->
|
||||||
<header class="ai-explain__top">
|
<AppPageHeader :title="title" size="detail" back>
|
||||||
<button type="button" class="ai-explain__back" @click="back">
|
|
||||||
<AppIcon name="chevron-left" :size="20" />
|
|
||||||
</button>
|
|
||||||
<h1 class="ai-explain__title">{{ title }}</h1>
|
|
||||||
<AppBadge :variant="isAi ? 'primary' : 'soft'">
|
<AppBadge :variant="isAi ? 'primary' : 'soft'">
|
||||||
{{ isAi ? 'AI 生成' : '题库解析回退' }}
|
{{ isAi ? 'AI 生成' : '题库解析回退' }}
|
||||||
</AppBadge>
|
</AppBadge>
|
||||||
</header>
|
</AppPageHeader>
|
||||||
|
|
||||||
<!-- 结论 -->
|
<!-- 结论 -->
|
||||||
<AppCard title="讲解结论" icon="sparkles">
|
<AppCard title="讲解结论" icon="sparkles">
|
||||||
@ -131,30 +128,6 @@ const mistakeText = computed(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
}
|
}
|
||||||
.ai-explain__top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.ai-explain__back {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
flex: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.ai-explain__title {
|
|
||||||
flex: 1;
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--fs-h2);
|
|
||||||
font-weight: var(--fw-h2);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.ai-explain__summary {
|
.ai-explain__summary {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--fs-body);
|
font-size: var(--fs-body);
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { dashboardApi, practiceApi, reviewApi, type ReviewListItem } from '../..
|
|||||||
import { useRequest } from '../../composables/useRequest'
|
import { useRequest } from '../../composables/useRequest'
|
||||||
import { useResponsive } from '../../composables/useResponsive'
|
import { useResponsive } from '../../composables/useResponsive'
|
||||||
import { usePracticeStore } from '../../stores/practice'
|
import { usePracticeStore } from '../../stores/practice'
|
||||||
|
import { useAppStore } from '../../stores/app'
|
||||||
import AppPageHeader from '../../components/base/AppPageHeader.vue'
|
import AppPageHeader from '../../components/base/AppPageHeader.vue'
|
||||||
import AppCard from '../../components/base/AppCard.vue'
|
import AppCard from '../../components/base/AppCard.vue'
|
||||||
import AppButton from '../../components/base/AppButton.vue'
|
import AppButton from '../../components/base/AppButton.vue'
|
||||||
@ -21,6 +22,7 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isMobile } = useResponsive()
|
const { isMobile } = useResponsive()
|
||||||
const practice = usePracticeStore()
|
const practice = usePracticeStore()
|
||||||
|
const app = useAppStore()
|
||||||
|
|
||||||
/** 当前子 tab:刷题 / 错题本 / 申论 */
|
/** 当前子 tab:刷题 / 错题本 / 申论 */
|
||||||
const tab = computed(() => {
|
const tab = computed(() => {
|
||||||
@ -132,6 +134,8 @@ async function startPractice() {
|
|||||||
total: session.total
|
total: session.total
|
||||||
})
|
})
|
||||||
router.push({ name: 'practice-session', params: { id: session.sessionId } })
|
router.push({ name: 'practice-session', params: { id: session.sessionId } })
|
||||||
|
} catch (err) {
|
||||||
|
app.toast(err instanceof Error ? err.message : '组卷失败,请稍后重试', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
starting.value = false
|
starting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { reviewApi, type ReviewDetail } from '../../api'
|
|||||||
import AppButton from '../../components/base/AppButton.vue'
|
import AppButton from '../../components/base/AppButton.vue'
|
||||||
import AppBadge from '../../components/base/AppBadge.vue'
|
import AppBadge from '../../components/base/AppBadge.vue'
|
||||||
import AppIcon from '../../components/base/AppIcon.vue'
|
import AppIcon from '../../components/base/AppIcon.vue'
|
||||||
|
import AppPageHeader from '../../components/base/AppPageHeader.vue'
|
||||||
|
import AppStack from '../../components/base/AppStack.vue'
|
||||||
import AppLoading from '../../components/feedback/AppLoading.vue'
|
import AppLoading from '../../components/feedback/AppLoading.vue'
|
||||||
import AppError from '../../components/feedback/AppError.vue'
|
import AppError from '../../components/feedback/AppError.vue'
|
||||||
import { formatDateShort, formatDateWeekday } from '../../utils/format'
|
import { formatDateShort, formatDateWeekday } from '../../utils/format'
|
||||||
@ -75,10 +77,6 @@ async function markMastered() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function back() {
|
|
||||||
router.back()
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusLabel = computed(() =>
|
const statusLabel = computed(() =>
|
||||||
assessResult.value
|
assessResult.value
|
||||||
? assessResult.value.status === 'mastered'
|
? assessResult.value.status === 'mastered'
|
||||||
@ -113,21 +111,15 @@ function goAiExplain() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="wrong-detail">
|
<AppStack class="wrong-detail">
|
||||||
<AppLoading v-if="loading" fullscreen text="正在加载错题详情…" />
|
<AppLoading v-if="loading" fullscreen text="正在加载错题详情…" />
|
||||||
<AppError v-else-if="error" fullscreen title="加载失败" :message="error" retry @retry="load" />
|
<AppError v-else-if="error" fullscreen title="加载失败" :message="error" retry @retry="load" />
|
||||||
|
|
||||||
<template v-else-if="detail">
|
<template v-else-if="detail">
|
||||||
<!-- 顶栏:返回 + 标题 + 状态 -->
|
<!-- 顶栏:返回 + 标题 + 状态 -->
|
||||||
<header class="wd__top">
|
<AppPageHeader title="错题详情" size="detail" back>
|
||||||
<button type="button" class="wd__back" @click="back">
|
<AppBadge :variant="statusMeta.variant">{{ statusLabel }}</AppBadge>
|
||||||
<AppIcon name="chevron-left" :size="20" />
|
</AppPageHeader>
|
||||||
</button>
|
|
||||||
<h1 class="wd__title">错题详情</h1>
|
|
||||||
<span class="wd__status-badge">
|
|
||||||
<AppBadge :variant="statusMeta.variant">{{ statusLabel }}</AppBadge>
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- 题目卡片 -->
|
<!-- 题目卡片 -->
|
||||||
<section class="wd__question">
|
<section class="wd__question">
|
||||||
@ -241,48 +233,17 @@ function goAiExplain() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</AppStack>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.wrong-detail {
|
.wrong-detail {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-5);
|
|
||||||
max-width: 680px;
|
max-width: 680px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶栏 */
|
/* 顶栏 */
|
||||||
.wd__top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.wd__back {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
flex: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.wd__title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--fs-h3);
|
|
||||||
font-weight: var(--fw-h1);
|
|
||||||
color: var(--text-primary);
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.wd__status-badge {
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 题目 */
|
/* 题目 */
|
||||||
.wd__question {
|
.wd__question {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
|
|||||||
@ -168,7 +168,7 @@ function editProfile() {
|
|||||||
|
|
||||||
<!-- ============ 桌面端我的 ============ -->
|
<!-- ============ 桌面端我的 ============ -->
|
||||||
<div v-else-if="d" class="profile-desktop">
|
<div v-else-if="d" class="profile-desktop">
|
||||||
<AppPageHeader title="我的" subtitle="管理个人资料与备考偏好">
|
<AppPageHeader title="我的" subtitle="管理个人资料与备考偏好" sticky-mobile>
|
||||||
<AppButton variant="secondary" @click="editProfile">
|
<AppButton variant="secondary" @click="editProfile">
|
||||||
<AppIcon name="user" :size="16" />编辑资料
|
<AppIcon name="user" :size="16" />编辑资料
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@ -326,7 +326,7 @@ function markAnswer(key: OptionKey) {
|
|||||||
<div class="questions">
|
<div class="questions">
|
||||||
<!-- ======================= 手动录入模式 ======================= -->
|
<!-- ======================= 手动录入模式 ======================= -->
|
||||||
<template v-if="mode === 'entry'">
|
<template v-if="mode === 'entry'">
|
||||||
<AppPageHeader title="题库管理" subtitle="手动录入题目 · 支持逐题与批量 JSON 导入">
|
<AppPageHeader title="题库管理" subtitle="手动录入题目 · 支持逐题与批量 JSON 导入" sticky-mobile>
|
||||||
<AppButton variant="ghost" @click="mode = 'list'">
|
<AppButton variant="ghost" @click="mode = 'list'">
|
||||||
<AppIcon name="chevron-left" :size="16" />返回列表
|
<AppIcon name="chevron-left" :size="16" />返回列表
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@ -454,7 +454,7 @@ function markAnswer(key: OptionKey) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<AppPageHeader title="题库管理" subtitle="手动录入题目 · 支持逐题与批量 JSON 导入">
|
<AppPageHeader title="题库管理" subtitle="手动录入题目 · 支持逐题与批量 JSON 导入" sticky-mobile>
|
||||||
<AppButton variant="secondary" @click="openImport">
|
<AppButton variant="secondary" @click="openImport">
|
||||||
<AppIcon name="inbox" :size="16" />导入真题
|
<AppIcon name="inbox" :size="16" />导入真题
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@ -96,7 +96,7 @@ async function save() {
|
|||||||
|
|
||||||
<template v-else-if="settings.data.value">
|
<template v-else-if="settings.data.value">
|
||||||
<!-- 页头 -->
|
<!-- 页头 -->
|
||||||
<AppPageHeader title="设置" subtitle="调整偏好、通知与数据策略" stack-mobile>
|
<AppPageHeader title="设置" subtitle="调整偏好、通知与数据策略" stack-mobile sticky-mobile>
|
||||||
<AppButton variant="primary" :disabled="saving" @click="save">
|
<AppButton variant="primary" :disabled="saving" @click="save">
|
||||||
<AppIcon name="check" :size="16" />{{ saving ? '保存中…' : '保存设置' }}
|
<AppIcon name="check" :size="16" />{{ saving ? '保存中…' : '保存设置' }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
91
docs/checks/task-12.md
Normal file
91
docs/checks/task-12.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# 任务 12 检查记录:联调与异常收口
|
||||||
|
|
||||||
|
日期:2026-09-02
|
||||||
|
|
||||||
|
## 本轮修复(查漏补缺)
|
||||||
|
|
||||||
|
1. `client/src/views/practice/PracticeView.vue`:**「开始刷题」缺失 catch**——组卷失败(如模块无题目 409、网络异常)时用户无任何反馈(未处理 Promise rejection)。补 catch + toast,复用统一错误文案。
|
||||||
|
2. `server/src/errors.ts` + `server/src/server.ts`:**非法 JSON 请求体返回 500**——Fastify 解析失败此前落入「未处理异常 → 500 INTERNAL_ERROR」。现解析器显式构造 400 错误并透出可理解文案「请求体不是合法 JSON,请检查格式」;AJV 校验分支保持原「请求参数不合法 + details」。
|
||||||
|
3. `server/src/handlers/questions.ts` + `news.ts` + `schemas/api.ts`:**导入「单条错误阻塞整批」与文档承诺不符**——此前请求级 schema 对题目/要闻逐条强制必填+枚举,任何一条无效整批 400,导致「错误题目不影响有效题目导入」从未真正生效(任务07 检查记录也仅验证了去重)。现改为:请求层只声明字段类型结构,handler 内用严格 per-item schema(`QuestionInputSchema` / `NewsImportItemSchema`)逐条 `safeParse`,收集可读中文错误(`importItemMessage`),有效项照常入库;重复项仍按指纹跳过。
|
||||||
|
4. `server/src/handlers/practice.ts`:**finish 对已结束会话缺少防护**(与 answer/currentQuestion 不一致)——补 `status==='finished' → 409 会话已结束`;清理过期注释(错题沉淀实际在单题作答时完成)。
|
||||||
|
|
||||||
|
## 接口检查清单(40 个接口)
|
||||||
|
|
||||||
|
### 数据中枢
|
||||||
|
| 接口 | 正常 | 空数据 | 校验/边界 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/dashboard/overview | 200 指标齐全 | 200 全 0 / pendingReview 联动 | — |
|
||||||
|
| GET /api/dashboard/trends | 200 | 200 空数组 | days 越界(0/91) → 400 |
|
||||||
|
| GET /api/dashboard/mastery | 200 五模块 | 200 全部 answered 0 | — |
|
||||||
|
| GET /api/dashboard/weak-points | 200 | 200 空 | — |
|
||||||
|
|
||||||
|
### 刷题
|
||||||
|
| 接口 | 正常 | 空数据 | 校验/边界 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/practice/modules | 200 五模块题量 | 200 全 0 | — |
|
||||||
|
| POST /api/practice/start | 200 会话 | 模块无题 → 409「该模块暂无题目,请先导入题目」 | 非法模块/时长 → 400;custom 空 → 409;custom 含不存在题目 → 409;>30 题 → 409 |
|
||||||
|
| GET /api/practice/:sessionId/question | 200 当前题 | 答完 → question:null | 不存在会话 → 404;已结束 → 409 |
|
||||||
|
| POST /api/practice/:sessionId/answer | 200 记分 | — | 重复提交 → duplicate 不重复计;乱序/跨题 → 409;已结束 → 409;题目被删 → 404 |
|
||||||
|
| POST /api/practice/:sessionId/finish | 200 结果 | — | 已结束再次 finish → 409(本轮补) |
|
||||||
|
| GET /api/practice/wrong-reasons | 200 | 200 空 | — |
|
||||||
|
|
||||||
|
### 错题复习
|
||||||
|
| 接口 | 正常 | 空数据 | 校验/边界 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET /api/review/list | 200 分页 | 200 total 0 | 非法 status/module → 400 |
|
||||||
|
| GET /api/review/:id/detail | 200 | — | 不存在 → 404;题目已删 → 404 |
|
||||||
|
| POST /api/review/:id/submit | 200 推进 +1/+3/+7 | — | 非法 answer → 400;不存在 → 404 |
|
||||||
|
| POST /api/review/:id/self-assess | 200 | — | 缺 correct → 400;不存在 → 404 |
|
||||||
|
| POST /api/review/:id/mark-mastered | 200 | — | 不存在 → 404 |
|
||||||
|
|
||||||
|
### 备考计划 / 模考 / 要闻 / 题库 / AI / 档案
|
||||||
|
| 接口 | 正常 | 空数据 | 校验/边界 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET/POST /api/plans/* | 200 | 200 空任务 | toggle 不存在 → 404;创建缺标题 → 400 |
|
||||||
|
| GET /api/plans/review | 200 | 200 全 0 | — |
|
||||||
|
| GET/POST/DELETE /api/mock-exams/* | 200 | 200 latest/gap 为 null | 分数越界 → 400;不存在记录 update/delete → 404 |
|
||||||
|
| GET /api/news/list + /:id | 200 | 200 | 分类筛选;不存在 → 404 |
|
||||||
|
| POST /api/news/import/json | 200 报告 | — | 逐条校验(本轮):有效导入、重复跳过、单条错误不再阻塞整批 |
|
||||||
|
| POST /api/news/import/{rss,api,url} | 200 占位反馈 | — | url 非法 → 400 |
|
||||||
|
| GET/POST /api/questions/list、import、export | 200 | 200 | 逐条校验(本轮):单条错误报告 + 有效项照常入库 |
|
||||||
|
| DELETE /api/questions/:id | 200 | — | 被引用 → 409 带明细;不存在 → 404 |
|
||||||
|
| GET /api/questions/:id/refs | 200 | — | 不存在 → 404 |
|
||||||
|
| POST /api/ai/explain-question | 200 | — | 未配置/超时/失败回退 fallback;题目不存在 → 404;非法 requestType → 400 |
|
||||||
|
| GET/PATCH /api/profile、/api/settings | 200 | 档案缺失时 fallback 默认值 | 非法日期/分数 → 400;aiConfigured 由环境变量计算 |
|
||||||
|
|
||||||
|
## 边界场景记录
|
||||||
|
|
||||||
|
| 场景 | 结果 | 处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 全数据文件为空(合法 []) | 全部 GET 200、空态正确;模块无题 start → 409 | 已有空态 + 本轮补前端 toast |
|
||||||
|
| 0 字节/损坏 JSON 文件(启动期) | 启动报「无法读取 xxx:SyntaxError…」,可定位 | 任务02 约定:缺失文件 fallback、非法 JSON 可定位错误 |
|
||||||
|
| 运行中损坏 questions.json | 该域 500(用户见通用文案),服务不崩;mock/news 等其它域 200;恢复文件后**无需重启**即恢复 | readData 每次读盘;日志含「无法读取 questions.json:…」 |
|
||||||
|
| 非法 JSON 请求体 | 400「请求体不是合法 JSON,请检查格式」(原 500) | 本轮修复 |
|
||||||
|
| 题目/要闻导入含无效条目 | 有效项入库 + 逐条中文错误报告(不再整批 400) | 本轮修复 |
|
||||||
|
| 重复导入 | 指纹去重:success 0 / skipped N | 既有 |
|
||||||
|
| 重复作答 | accepted:false + duplicate:true,不重复计入 | 既有 |
|
||||||
|
| 乱序作答 | 409「题目与会话作答顺序不符」 | 既有 |
|
||||||
|
| 重复 finish | 409「会话已结束」(原 200 重复写状态) | 本轮修复 |
|
||||||
|
| 刷题中心「开始刷题」失败 | toast 显示后端中文错误(原无反馈) | 本轮修复 |
|
||||||
|
| 网络断开(后端未启动) | 页面 AppError「无法连接服务,请确认后端已启动」+ 重试 | 既有 unwrap + 页面四态 |
|
||||||
|
| AI 未配置/超时/失败 | fallback 可识别提示,不阻塞流程;Token 不进日志 | 任务11 |
|
||||||
|
| 删除被引用题目 | 409 + 引用明细,前端弹层禁用确认 | 既有 |
|
||||||
|
| 前端提交类操作 | 保存/导入/删除/自评/标记等均 try/catch + toast / 错误态;进行中按钮 disabled/loading | 审计通过(仅开始刷题缺 catch,已修) |
|
||||||
|
| 敏感配置 | server/.env gitignored;AI Key 不进入日志、OpenAPI、前端 | 任务11 + 88c479f |
|
||||||
|
|
||||||
|
## 完成标准核对(开发计划任务12)
|
||||||
|
|
||||||
|
- ✅ 逐接口联调(40 个接口按域清单核对:正常 / 空数据 / 非法输入 / 业务边界)。
|
||||||
|
- ✅ 空数据处理(全空 JSON 文件下各页空态 + CTA)。
|
||||||
|
- ✅ 非法导入(题目/要闻单条无效不再阻塞整批,逐条中文错误报告)。
|
||||||
|
- ✅ 重复提交(作答幂等、重复 finish 409、重复导入去重)。
|
||||||
|
- ✅ 文件写入失败 / 损坏文件(可定位日志、服务不崩、恢复无需重启)。
|
||||||
|
- ✅ AI 失败(未配置/超时/解析失败回退题库解析,不阻塞流程)。
|
||||||
|
- ✅ 网络断开(统一「无法连接服务」+ 重试)。
|
||||||
|
- ✅ 统一按钮禁用与提示文案(缺失的「开始刷题」catch 已补,toast 文案与后端错误对齐)。
|
||||||
|
- ✅ 服务端日志可定位问题(文件路径 + 错误原因)且不泄露敏感配置(无请求体、无 Token)。
|
||||||
|
- ✅ 类型检查(vue-tsc / tsc)与生产构建(139 模块)通过;OpenAPI / 生成 API / 实现一致(api:generate 无 diff)。
|
||||||
|
|
||||||
|
## 当前结论
|
||||||
|
|
||||||
|
任务 12 完成:本轮审计发现并修复 4 类真实缺口(开始刷题无错误反馈、非法 JSON body 误报 500、导入单条错误阻塞整批、finish 重复调用无防护);40 个接口边界、损坏文件、网络断开、AI 失败等场景全部实测通过;接口检查清单与边界场景记录如上。测试数据均在临时目录,仓库演示数据未受影响。
|
||||||
@ -96,8 +96,12 @@ export function errorHandler(error: FastifyError | ApiError | Error, request: Fa
|
|||||||
|
|
||||||
const fastifyError = error as FastifyError
|
const fastifyError = error as FastifyError
|
||||||
if (fastifyError.validation || fastifyError.statusCode === 400) {
|
if (fastifyError.validation || fastifyError.statusCode === 400) {
|
||||||
|
if (!fastifyError.validation) {
|
||||||
|
// 非 AJV 的 400(如 JSON body 解析失败),直接透出可理解的原因
|
||||||
|
return sendError(reply, 400, 'VALIDATION_ERROR', fastifyError.message || '请求参数不合法', [])
|
||||||
|
}
|
||||||
const details = consolidateDetails(
|
const details = consolidateDetails(
|
||||||
(fastifyError.validation ?? []).map((item) => ({
|
fastifyError.validation.map((item) => ({
|
||||||
path: item.instancePath.replace(/^\//, '') || (item.params as { missingProperty?: string })?.missingProperty || '',
|
path: item.instancePath.replace(/^\//, '') || (item.params as { missingProperty?: string })?.missingProperty || '',
|
||||||
message: item.message ?? '参数不合法'
|
message: item.message ?? '参数不合法'
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -7,10 +7,12 @@ import type { NewsItem } from '../schemas/entities.js'
|
|||||||
import type { z } from 'zod'
|
import type { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
NewsImportApiBodySchema,
|
NewsImportApiBodySchema,
|
||||||
|
NewsImportItemSchema,
|
||||||
NewsImportJsonBodySchema,
|
NewsImportJsonBodySchema,
|
||||||
NewsImportRssBodySchema,
|
NewsImportRssBodySchema,
|
||||||
NewsImportUrlBodySchema,
|
NewsImportUrlBodySchema,
|
||||||
NewsListQuerySchema
|
NewsListQuerySchema,
|
||||||
|
importItemMessage
|
||||||
} from '../schemas/api.js'
|
} from '../schemas/api.js'
|
||||||
|
|
||||||
/** 排序:按发布时间倒序(用于列表) */
|
/** 排序:按发布时间倒序(用于列表) */
|
||||||
@ -55,28 +57,30 @@ export async function importJson(request: FastifyRequest) {
|
|||||||
const added: NewsItem[] = []
|
const added: NewsItem[] = []
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
|
|
||||||
body.news.forEach((item, index) => {
|
body.news.forEach((raw, index) => {
|
||||||
try {
|
const parsed = NewsImportItemSchema.safeParse(raw)
|
||||||
const fingerprint = `${item.title}\u0000${item.category}\u0000${item.publishedAt}`
|
if (!parsed.success) {
|
||||||
if (seen.has(fingerprint)) {
|
errors.push({ index, message: importItemMessage(parsed.error) })
|
||||||
skipped += 1
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
seen.add(fingerprint)
|
|
||||||
added.push({
|
|
||||||
id: newId('news'),
|
|
||||||
title: item.title,
|
|
||||||
category: item.category,
|
|
||||||
summary: item.summary,
|
|
||||||
content: item.content,
|
|
||||||
source: item.source,
|
|
||||||
publishedAt: item.publishedAt,
|
|
||||||
tags: item.tags,
|
|
||||||
importSource: item.importSource
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
errors.push({ index, message: error instanceof Error ? error.message : '导入失败' })
|
|
||||||
}
|
}
|
||||||
|
const item = parsed.data
|
||||||
|
const fingerprint = `${item.title}\u0000${item.category}\u0000${item.publishedAt}`
|
||||||
|
if (seen.has(fingerprint)) {
|
||||||
|
skipped += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen.add(fingerprint)
|
||||||
|
added.push({
|
||||||
|
id: newId('news'),
|
||||||
|
title: item.title,
|
||||||
|
category: item.category,
|
||||||
|
summary: item.summary,
|
||||||
|
content: item.content,
|
||||||
|
source: item.source,
|
||||||
|
publishedAt: item.publishedAt,
|
||||||
|
tags: item.tags,
|
||||||
|
importSource: item.importSource
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (added.length > 0) {
|
if (added.length > 0) {
|
||||||
|
|||||||
@ -261,13 +261,14 @@ export async function wrongReasons() {
|
|||||||
* 结束会话并返回结果:
|
* 结束会话并返回结果:
|
||||||
* - 只对已作答的题计分(未作答不计入正确率);
|
* - 只对已作答的题计分(未作答不计入正确率);
|
||||||
* - 计算得分、正确率、总耗时;
|
* - 计算得分、正确率、总耗时;
|
||||||
* - 标记会话 finished;若仍有未答题,不允许结束(需先答完)。
|
* - 标记会话 finished;已结束会话重复调用返回 409;
|
||||||
* 说明:错题沉淀(写入 wrong-questions.json)属于任务 09,本接口暂不落库,
|
* - 错题沉淀在单题作答时(practice.answer 答错)已写入 wrong-questions.json(任务 09),
|
||||||
* 但返回错题列表供前端展示与后续移交。
|
* 本接口仅汇总返回本卷错题列表,供结果页展示。
|
||||||
*/
|
*/
|
||||||
export async function finish(request: FastifyRequest) {
|
export async function finish(request: FastifyRequest) {
|
||||||
const { sessionId } = request.params as { sessionId: string }
|
const { sessionId } = request.params as { sessionId: string }
|
||||||
const session = await findSession(sessionId)
|
const session = await findSession(sessionId)
|
||||||
|
if (session.status === 'finished') throw ApiError.conflict('会话已结束')
|
||||||
|
|
||||||
const [records, questions] = await Promise.all([
|
const [records, questions] = await Promise.all([
|
||||||
readData<PracticeRecord[]>(dataFiles.practiceRecords, []),
|
readData<PracticeRecord[]>(dataFiles.practiceRecords, []),
|
||||||
|
|||||||
@ -10,7 +10,12 @@ import type {
|
|||||||
WrongQuestion
|
WrongQuestion
|
||||||
} from '../schemas/entities.js'
|
} from '../schemas/entities.js'
|
||||||
import type { z } from 'zod'
|
import type { z } from 'zod'
|
||||||
import { QuestionsImportBodySchema, QuestionsListQuerySchema } from '../schemas/api.js'
|
import {
|
||||||
|
importItemMessage,
|
||||||
|
QuestionImportItemSchema,
|
||||||
|
QuestionsImportBodySchema,
|
||||||
|
QuestionsListQuerySchema
|
||||||
|
} from '../schemas/api.js'
|
||||||
|
|
||||||
/** 内容指纹:题干 + 模块 + 答案 + 选项文本(排序后),用于导入去重 */
|
/** 内容指纹:题干 + 模块 + 答案 + 选项文本(排序后),用于导入去重 */
|
||||||
function contentFingerprint(q: Pick<Question, 'stem' | 'module' | 'answer' | 'options'>) {
|
function contentFingerprint(q: Pick<Question, 'stem' | 'module' | 'answer' | 'options'>) {
|
||||||
@ -52,31 +57,33 @@ export async function importQuestions(request: FastifyRequest) {
|
|||||||
const added: Question[] = []
|
const added: Question[] = []
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
|
|
||||||
body.questions.forEach((item, index) => {
|
body.questions.forEach((raw, index) => {
|
||||||
try {
|
const parsed = QuestionImportItemSchema.safeParse(raw)
|
||||||
const fingerprint = contentFingerprint(item)
|
if (!parsed.success) {
|
||||||
if (seen.has(fingerprint)) {
|
errors.push({ index, message: importItemMessage(parsed.error) })
|
||||||
skipped += 1
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
seen.add(fingerprint)
|
|
||||||
added.push({
|
|
||||||
id: newId('q'),
|
|
||||||
type: item.type,
|
|
||||||
module: item.module,
|
|
||||||
subModule: item.subModule,
|
|
||||||
difficulty: item.difficulty,
|
|
||||||
stem: item.stem,
|
|
||||||
options: item.options,
|
|
||||||
answer: item.answer,
|
|
||||||
analysis: item.analysis,
|
|
||||||
tags: item.tags,
|
|
||||||
source: item.source,
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
errors.push({ index, message: error instanceof Error ? error.message : '导入失败' })
|
|
||||||
}
|
}
|
||||||
|
const item = parsed.data
|
||||||
|
const fingerprint = contentFingerprint(item)
|
||||||
|
if (seen.has(fingerprint)) {
|
||||||
|
skipped += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen.add(fingerprint)
|
||||||
|
added.push({
|
||||||
|
id: newId('q'),
|
||||||
|
type: item.type,
|
||||||
|
module: item.module,
|
||||||
|
subModule: item.subModule,
|
||||||
|
difficulty: item.difficulty,
|
||||||
|
stem: item.stem,
|
||||||
|
options: item.options,
|
||||||
|
answer: item.answer,
|
||||||
|
analysis: item.analysis,
|
||||||
|
tags: item.tags,
|
||||||
|
source: item.source,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (added.length > 0) {
|
if (added.length > 0) {
|
||||||
|
|||||||
@ -5,11 +5,43 @@ import {
|
|||||||
DIFFICULTIES,
|
DIFFICULTIES,
|
||||||
NewsItemSchema,
|
NewsItemSchema,
|
||||||
ProfileSchema,
|
ProfileSchema,
|
||||||
|
QuestionInputSchema,
|
||||||
QuestionSchema,
|
QuestionSchema,
|
||||||
SettingsSchema,
|
SettingsSchema,
|
||||||
StudyPlanSchema
|
StudyPlanSchema
|
||||||
} from './entities.js'
|
} from './entities.js'
|
||||||
|
|
||||||
|
/** 导入报告逐条错误的中文可读信息(Zod 校验失败时调用) */
|
||||||
|
export function importItemMessage(error: z.ZodError): string {
|
||||||
|
const first = error.issues[0]
|
||||||
|
if (!first) return '字段不合法'
|
||||||
|
const field = first.path.join('.') || '条目'
|
||||||
|
const extra = first as { received?: string; expected?: string; type?: string; minimum?: number; keys?: string[] }
|
||||||
|
switch (first.code) {
|
||||||
|
case 'invalid_type':
|
||||||
|
if (extra.received === 'undefined') return `缺少必填字段 ${field}`
|
||||||
|
return `${field} 类型应为${extra.expected ?? '正确类型'}`
|
||||||
|
case 'invalid_value':
|
||||||
|
return `${field} 取值必须是允许的枚举值之一`
|
||||||
|
case 'too_small':
|
||||||
|
if (extra.type === 'array') return `${field} 至少需要 ${extra.minimum ?? 1} 项`
|
||||||
|
if (extra.type === 'string') return `${field} 不能为空`
|
||||||
|
return `${field} 取值不符合要求`
|
||||||
|
case 'too_big':
|
||||||
|
return `${field} 取值超出上限`
|
||||||
|
case 'invalid_format':
|
||||||
|
return `${field} 格式不正确`
|
||||||
|
case 'invalid_union':
|
||||||
|
return `${field} 取值不符合预设选项`
|
||||||
|
case 'invalid_element':
|
||||||
|
return `${field} 内部存在不合法项`
|
||||||
|
case 'unrecognized_keys':
|
||||||
|
return `${field} 包含不支持字段:${(extra.keys ?? []).join('、')}`
|
||||||
|
default:
|
||||||
|
return `${field} 不合法`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 统一错误响应(需求文档第 6 节格式) */
|
/** 统一错误响应(需求文档第 6 节格式) */
|
||||||
export const ErrorDetailSchema = z.object({
|
export const ErrorDetailSchema = z.object({
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
@ -385,18 +417,27 @@ export const NewsIdParamsSchema = z.object({ id: z.string().min(1) })
|
|||||||
export const NewsImportJsonBodySchema = z.object({
|
export const NewsImportJsonBodySchema = z.object({
|
||||||
version: z.string().optional(),
|
version: z.string().optional(),
|
||||||
news: z.array(
|
news: z.array(
|
||||||
NewsItemSchema.omit({ id: true }).extend({
|
// 请求层只约束字段类型;逐条「必填/非空/枚举」语义校验在 handler 内完成,
|
||||||
title: z.string().min(1),
|
// 保证单条错误不阻塞其它有效条目(返回逐条错误报告)。
|
||||||
category: z.string().min(1),
|
z
|
||||||
summary: z.string(),
|
.object({
|
||||||
content: z.string(),
|
title: z.string().optional(),
|
||||||
source: z.string(),
|
category: z.string().optional(),
|
||||||
publishedAt: z.string(),
|
summary: z.string().optional(),
|
||||||
tags: z.array(z.string()).default([]),
|
content: z.string().optional(),
|
||||||
importSource: z.string().default('json')
|
source: z.string().optional(),
|
||||||
})
|
publishedAt: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
importSource: z.string().optional()
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
/** 要闻逐条严格校验(用于 handler 内 safeParse,默认值在此落地) */
|
||||||
|
export const NewsImportItemSchema = NewsItemSchema.omit({ id: true }).extend({
|
||||||
|
tags: z.array(z.string()).default([]),
|
||||||
|
importSource: z.string().default('json')
|
||||||
|
})
|
||||||
export const NewsImportRssBodySchema = z.object({ url: z.string().url() })
|
export const NewsImportRssBodySchema = z.object({ url: z.string().url() })
|
||||||
export const NewsImportApiBodySchema = z.object({
|
export const NewsImportApiBodySchema = z.object({
|
||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
@ -417,9 +458,27 @@ export const QuestionIdParamsSchema = z.object({ id: z.string().min(1) })
|
|||||||
export const QuestionsImportBodySchema = z.object({
|
export const QuestionsImportBodySchema = z.object({
|
||||||
version: z.string().optional(),
|
version: z.string().optional(),
|
||||||
questions: z.array(
|
questions: z.array(
|
||||||
QuestionSchema.omit({ id: true, createdAt: true })
|
// 同要闻导入:请求层宽松,逐条严格校验(QuestionInputSchema)在 handler 内完成
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.string().optional(),
|
||||||
|
module: z.string().optional(),
|
||||||
|
subModule: z.string().optional(),
|
||||||
|
difficulty: z.string().optional(),
|
||||||
|
stem: z.string().optional(),
|
||||||
|
options: z
|
||||||
|
.array(z.object({ key: z.string().optional(), text: z.string().optional() }))
|
||||||
|
.optional(),
|
||||||
|
answer: z.string().optional(),
|
||||||
|
analysis: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
source: z.string().optional()
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
/** 题目逐条严格校验(与导出格式一致,供 handler 内 safeParse) */
|
||||||
|
export const QuestionImportItemSchema = QuestionInputSchema
|
||||||
export const QuestionsExportResponseSchema = z.object({
|
export const QuestionsExportResponseSchema = z.object({
|
||||||
version: z.string(),
|
version: z.string(),
|
||||||
exportedAt: z.string(),
|
exportedAt: z.string(),
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
import './env.js'
|
import './env.js'
|
||||||
|
|
||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
import Fastify from 'fastify'
|
import Fastify, { type FastifyError } from 'fastify'
|
||||||
import cors from '@fastify/cors'
|
import cors from '@fastify/cors'
|
||||||
import swagger from '@fastify/swagger'
|
import swagger from '@fastify/swagger'
|
||||||
import swaggerUi from '@fastify/swagger-ui'
|
import swaggerUi from '@fastify/swagger-ui'
|
||||||
@ -21,7 +21,9 @@ export async function buildServer() {
|
|||||||
try {
|
try {
|
||||||
done(null, JSON.parse(body as string))
|
done(null, JSON.parse(body as string))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
done(err as Error, undefined)
|
const parseError = new Error('请求体不是合法 JSON,请检查格式') as FastifyError
|
||||||
|
parseError.statusCode = 400
|
||||||
|
done(parseError, undefined)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user