周报小程序

master
lion 2 months ago
parent 517080ddaa
commit bf3be1cd76

@ -0,0 +1,94 @@
import { API_BASE, TOKEN_STORAGE_KEY } from '@/config'
import { request } from '@/utils/request'
import type { Paginated } from '@/types/api'
export interface WeeklyBriefStats {
papers_count?: number
papers_analyzed?: number
high_value_papers_count?: number
news_count?: number
news_analyzed?: number
teachers_count?: number
references_count?: number
}
export interface WeeklyBriefRow {
id: number
week_start: string
week_end: string
title: string
stats?: WeeklyBriefStats | null
generated_at?: string | null
has_docx?: boolean
}
export interface WeeklyBriefDetail extends WeeklyBriefRow {
content?: string
}
export interface WeeklyBriefWeekOption {
offset: number
label: string
week_start: string
week_end: string
}
function buildQuery(params?: Record<string, unknown>) {
if (!params) return ''
const query = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
.join('&')
return query ? `?${query}` : ''
}
export const weeklyBriefApi = {
list(params?: { page?: number; page_size?: number }) {
return request<Paginated<WeeklyBriefRow>>(`/weekly-briefs${buildQuery(params)}`, { auth: true })
},
detail(id: number) {
return request<WeeklyBriefDetail>(`/weekly-briefs/${id}`, { auth: true })
},
weekOptions() {
return request<{ items: WeeklyBriefWeekOption[] }>('/weekly-briefs/week-options', { auth: true })
},
generate(payload: { week_offset?: number; week_start?: string; week_end?: string } = {}) {
return request<WeeklyBriefDetail>('/weekly-briefs/generate', {
method: 'POST',
data: payload,
auth: true,
})
},
downloadDocx(id: number, filename: string) {
const token = uni.getStorageSync(TOKEN_STORAGE_KEY) as string
return new Promise<void>((resolve, reject) => {
uni.downloadFile({
url: `${API_BASE}/weekly-briefs/${id}/download`,
header: token ? { Authorization: `Bearer ${token}` } : {},
success: (res) => {
if (res.statusCode !== 200 || !res.tempFilePath) {
reject(new Error('下载失败'))
return
}
uni.openDocument({
filePath: res.tempFilePath,
fileType: 'docx',
showMenu: true,
success: () => resolve(),
fail: () => {
uni.saveFile({
tempFilePath: res.tempFilePath,
success: () => {
uni.showToast({ title: '已保存文件', icon: 'none' })
resolve()
},
fail: (err) => reject(new Error(err.errMsg || '打开文件失败')),
})
},
})
},
fail: (err) => reject(new Error(err.errMsg || '下载失败')),
})
})
},
}

@ -9,7 +9,7 @@ import { computed } from 'vue'
import { toSvgDataUri } from '@/utils/svg'
const props = defineProps<{
type: 'profile' | 'course' | 'activity' | 'demand' | 'checkin' | 'logout' | 'crawler'
type: 'profile' | 'course' | 'activity' | 'demand' | 'checkin' | 'logout' | 'crawler' | 'weekly-brief'
}>()
const iconMap = {
@ -53,6 +53,12 @@ const iconMap = {
<path d="M12 4v3M8.5 17.5 12 15l3.5 2.5" stroke="#244e98" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.72"/>
</svg>
`),
'weekly-brief': toSvgDataUri(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect x="6" y="4.5" width="12" height="15" rx="1.5" stroke="#244e98" stroke-width="1.8" stroke-opacity="0.72"/>
<path d="M9 9h6M9 12h6M9 15h4" stroke="#244e98" stroke-width="1.8" stroke-linecap="round" stroke-opacity="0.72"/>
</svg>
`),
}
const iconSrc = computed(() => iconMap[props.type])

@ -214,6 +214,14 @@
"navigationBarBackgroundColor": "#244e98",
"navigationBarTextStyle": "white"
}
},
{
"path": "weekly-brief/index",
"style": {
"navigationBarTitleText": "论文周报",
"navigationBarBackgroundColor": "#244e98",
"navigationBarTextStyle": "white"
}
}
]
}

@ -31,6 +31,11 @@
<text class="menu-copy">数据爬虫</text>
<text class="arrow"></text>
</view>
<view v-if="showCrawlerMenu" class="menu-item" @tap="go('/subpkg/weekly-brief/index')">
<ProfileMenuIcon type="weekly-brief" />
<text class="menu-copy">论文周报</text>
<text class="arrow"></text>
</view>
</view>
<!-- #ifndef MP-WEIXIN -->
<AppTabBar />

@ -0,0 +1,551 @@
<template>
<view class="page-wrap profile-bg weekly-brief-page">
<view class="card intro-card">
<text class="intro-title">AI 科技成果周报</text>
<text class="intro-desc">基于爬虫入库的论文与资讯按周自动生成简报可选择本周或历史自然周</text>
<view class="generate-row">
<view class="picker-wrap">
<picker
class="picker-field"
:range="weekOptionLabels"
:value="weekOptionIndex"
@change="onWeekChange"
>
<view class="select">{{ weekOptionLabels[weekOptionIndex] || '选择统计周' }}</view>
</picker>
</view>
<button class="btn btn-primary generate-btn" :loading="generating" @tap="onGenerate"></button>
</view>
</view>
<view class="card list-card">
<text class="section-title">历史简报</text>
<view v-if="loading && !items.length" class="empty-hint">...</view>
<view v-else-if="!items.length" class="empty-hint">暂无周报请先生成</view>
<view v-else class="brief-list">
<view v-for="item in items" :key="item.id" class="brief-item" @tap="openDetail(item.id)">
<text class="brief-title">{{ item.title }}</text>
<text class="brief-meta">{{ formatWeekRange(item.week_start, item.week_end) }}</text>
<text class="brief-meta">
论文 {{ item.stats?.papers_count ?? 0 }} · 资讯 {{ item.stats?.news_count ?? 0 }} ·
{{ formatGeneratedAt(item.generated_at) }}
</text>
<view class="brief-actions" @tap.stop>
<button
v-if="item.has_docx"
class="action-btn"
size="mini"
@tap="downloadDocx(item)"
>
下载 Word
</button>
<text class="arrow"></text>
</view>
</view>
</view>
<view v-if="hasMore" class="load-more-wrap">
<button class="load-more-btn" :loading="loadingMore" @tap="loadMore"></button>
</view>
</view>
<view v-if="detailVisible" class="detail-mask" @tap="closeDetail">
<view class="detail-panel" @tap.stop>
<view class="detail-head">
<text class="detail-title">{{ detail?.title || 'AI 科技成果周报' }}</text>
<text class="detail-close" @tap="closeDetail">×</text>
</view>
<view v-if="detail" class="detail-meta-row">
<text>{{ formatWeekRange(detail.week_start, detail.week_end) }}</text>
<text>论文 {{ detail.stats?.papers_count ?? 0 }}</text>
<text>资讯 {{ detail.stats?.news_count ?? 0 }}</text>
<text v-if="detail.stats?.teachers_count != null"> {{ detail.stats.teachers_count }}</text>
</view>
<scroll-view v-if="detail" scroll-y class="detail-scroll">
<text class="detail-content">{{ detail.content || '暂无简报正文,请重新生成' }}</text>
</scroll-view>
<view v-else class="detail-loading">加载中...</view>
<view class="detail-footer">
<button class="footer-btn btn" :disabled="!detail?.content" @tap="copyContent"></button>
<button
class="footer-btn btn primary"
:disabled="!detail?.has_docx"
@tap="downloadDocx(detail)"
>
下载 Word
</button>
</view>
</view>
</view>
</view>
</template>
<script lang="ts">
import { getShareAppMessage, getShareTimelineMessage } from '@/utils/page-share-handlers'
export default {
onShareAppMessage: getShareAppMessage,
onShareTimeline: getShareTimelineMessage,
}
</script>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import {
weeklyBriefApi,
type WeeklyBriefDetail,
type WeeklyBriefRow,
type WeeklyBriefWeekOption,
} from '@/api/weekly-brief'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const loading = ref(false)
const loadingMore = ref(false)
const generating = ref(false)
const items = ref<WeeklyBriefRow[]>([])
const page = ref(1)
const lastPage = ref(1)
const weekOptions = ref<WeeklyBriefWeekOption[]>([])
const weekOptionIndex = ref(0)
const weekOffset = ref(0)
const detailVisible = ref(false)
const detail = ref<WeeklyBriefDetail | null>(null)
const weekOptionLabels = computed(() =>
weekOptions.value.map(
(opt) => `${opt.label}${opt.week_start} ~ ${opt.week_end}`,
),
)
const hasMore = computed(() => page.value < lastPage.value)
function formatWeekRange(start?: string | null, end?: string | null) {
if (!start || !end) return '—'
return `${start}${end}`
}
function formatGeneratedAt(iso?: string | null) {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function loadWeekOptions() {
try {
const res = await weeklyBriefApi.weekOptions()
weekOptions.value = res.items || []
if (
weekOptions.value.length &&
!weekOptions.value.some((opt) => opt.offset === weekOffset.value)
) {
weekOffset.value = weekOptions.value[0].offset
weekOptionIndex.value = 0
} else {
weekOptionIndex.value = Math.max(
0,
weekOptions.value.findIndex((opt) => opt.offset === weekOffset.value),
)
}
} catch {
weekOptions.value = []
}
}
async function loadBriefs(reset = false) {
if (reset) {
page.value = 1
items.value = []
}
const isFirstPage = page.value === 1
if (isFirstPage) {
loading.value = true
} else {
loadingMore.value = true
}
try {
const res = await weeklyBriefApi.list({ page: page.value, page_size: 10 })
items.value = reset ? res.items : [...items.value, ...res.items]
lastPage.value = res.meta.last_page
} catch (error) {
if (reset) items.value = []
uni.showToast({
title: error instanceof Error ? error.message : '加载失败',
icon: 'none',
})
} finally {
loading.value = false
loadingMore.value = false
}
}
async function loadMore() {
if (!hasMore.value || loadingMore.value) return
page.value += 1
await loadBriefs()
}
function onWeekChange(event: UniHelper.PickerChangeEvent) {
weekOptionIndex.value = Number(event.detail.value)
weekOffset.value = weekOptions.value[weekOptionIndex.value]?.offset ?? 0
}
async function onGenerate() {
generating.value = true
try {
const brief = await weeklyBriefApi.generate({ week_offset: weekOffset.value })
uni.showToast({ title: '周报已生成', icon: 'none' })
await loadBriefs(true)
await openDetail(brief.id)
} catch (error) {
uni.showToast({
title: error instanceof Error ? error.message : '周报生成失败',
icon: 'none',
})
} finally {
generating.value = false
}
}
async function openDetail(id: number) {
detailVisible.value = true
detail.value = null
try {
detail.value = await weeklyBriefApi.detail(id)
} catch (error) {
detailVisible.value = false
uni.showToast({
title: error instanceof Error ? error.message : '加载周报失败',
icon: 'none',
})
}
}
function closeDetail() {
detailVisible.value = false
detail.value = null
}
function copyContent() {
const text = detail.value?.content?.trim()
if (!text) {
uni.showToast({ title: '暂无简报正文', icon: 'none' })
return
}
uni.setClipboardData({
data: text,
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
})
}
async function downloadDocx(brief?: WeeklyBriefRow | WeeklyBriefDetail | null) {
if (!brief?.id || !brief.has_docx) return
const filename = `高校科技成果周报_${brief.week_start}_${brief.week_end}.docx`
try {
await weeklyBriefApi.downloadDocx(brief.id, filename)
} catch (error) {
uni.showToast({
title: error instanceof Error ? error.message : '下载失败',
icon: 'none',
})
}
}
onShow(async () => {
if (!userStore.isLoggedIn) {
uni.navigateTo({ url: '/subpkg/login/index' })
return
}
try {
await userStore.fetchMe()
} catch {
// ignore
}
if (!userStore.isStaff) {
uni.showToast({ title: '无权使用论文周报', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1200)
return
}
await loadWeekOptions()
await loadBriefs(true)
})
</script>
<style scoped lang="scss">
@import '@/styles/page.scss';
@import '@/styles/card.scss';
@import '@/styles/tokens.scss';
.weekly-brief-page {
box-sizing: border-box;
}
.intro-card,
.list-card {
box-sizing: border-box;
width: 100%;
max-width: 100%;
margin-bottom: $section-gap;
padding: 32rpx;
overflow: hidden;
}
.intro-title {
display: block;
color: #111827;
font-size: 32rpx;
font-weight: 600;
}
.intro-desc {
display: block;
margin-top: 12rpx;
color: #6b7280;
font-size: 26rpx;
line-height: 1.6;
}
.generate-row {
display: flex;
flex-direction: column;
gap: 20rpx;
width: 100%;
min-width: 0;
margin-top: 24rpx;
overflow: hidden;
}
.picker-wrap {
width: 100%;
min-width: 0;
overflow: hidden;
}
.picker-field {
display: block;
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.select {
display: flex;
align-items: center;
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-height: 84rpx;
padding: 16rpx 24rpx;
overflow: hidden;
border: 1px solid #d6dde8;
border-radius: 16rpx;
background: #fff;
color: #111827;
font-size: 26rpx;
line-height: 1.4;
white-space: nowrap;
text-overflow: ellipsis;
}
.generate-btn {
width: 100%;
min-height: 88rpx;
border-radius: 16rpx;
font-size: 30rpx;
}
.generate-btn::after {
border: none;
}
.section-title {
display: block;
margin-bottom: 20rpx;
color: #111827;
font-size: 30rpx;
font-weight: 500;
}
.empty-hint {
padding: 40rpx 0;
color: #9ca3af;
font-size: 26rpx;
text-align: center;
}
.brief-item {
position: relative;
padding: 24rpx 56rpx 24rpx 0;
border-bottom: 1px solid #eef2f6;
}
.brief-item:last-child {
border-bottom: 0;
}
.brief-title {
display: block;
color: #111827;
font-size: 28rpx;
font-weight: 500;
line-height: 1.5;
}
.brief-meta {
display: block;
margin-top: 8rpx;
color: #6b7280;
font-size: 24rpx;
line-height: 1.5;
}
.brief-actions {
position: absolute;
top: 50%;
right: 0;
display: flex;
align-items: center;
gap: 8rpx;
transform: translateY(-50%);
}
.action-btn {
margin: 0;
padding: 0 16rpx;
color: #244e98;
font-size: 22rpx;
line-height: 1.8;
background: transparent;
}
.action-btn::after {
border: 0;
}
.arrow {
color: #9ca3af;
font-size: 36rpx;
}
.load-more-wrap {
padding-top: 16rpx;
}
.load-more-btn {
width: 100%;
min-height: 72rpx;
color: #244e98;
font-size: 28rpx;
background: #f8fafc;
}
.load-more-btn::after {
border: 0;
}
.detail-mask {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: flex-end;
background: rgba(15, 23, 42, 0.35);
}
.detail-panel {
display: flex;
flex-direction: column;
width: 100%;
max-height: 82vh;
padding: 28rpx 28rpx calc(28rpx + env(safe-area-inset-bottom));
border-radius: 24rpx 24rpx 0 0;
background: #fff;
}
.detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.detail-title {
flex: 1;
color: #111827;
font-size: 32rpx;
font-weight: 600;
line-height: 1.4;
}
.detail-close {
color: #9ca3af;
font-size: 44rpx;
line-height: 1;
}
.detail-meta-row {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 16rpx;
color: #6b7280;
font-size: 24rpx;
}
.detail-scroll {
flex: 1;
min-height: 0;
max-height: 52vh;
margin-top: 20rpx;
}
.detail-content {
display: block;
color: #374151;
font-size: 26rpx;
line-height: 1.7;
white-space: pre-wrap;
}
.detail-loading {
padding: 48rpx 0;
color: #9ca3af;
font-size: 26rpx;
text-align: center;
}
.detail-footer {
display: flex;
gap: 16rpx;
margin-top: 24rpx;
}
.footer-btn {
flex: 1;
height: 80rpx;
min-height: 80rpx;
margin: 0;
padding: 0;
color: #374151;
font-size: 28rpx;
line-height: 1;
background: #f3f4f6;
}
.footer-btn.primary {
color: #fff;
background: #244e98;
}
.footer-btn::after {
border: 0;
}
.footer-btn[disabled] {
opacity: 0.55;
}
</style>
Loading…
Cancel
Save