master
lion 13 hours ago
parent c3b48567f0
commit bdccbd2a96

@ -308,7 +308,10 @@ export async function fetchRadarMap() {
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
sections?: Record<string, number>
@ -321,10 +324,12 @@ export interface WeeklyBriefRow {
title: string
stats?: WeeklyBriefStats | null
generated_at?: string | null
has_docx?: boolean
}
export interface WeeklyBriefDetail extends WeeklyBriefRow {
markdown: string
docx_path?: string | null
content?: string
}
export async function fetchWeeklyBriefs(params: Record<string, unknown>) {
@ -337,7 +342,38 @@ export async function fetchWeeklyBrief(id: number) {
return data.data
}
export async function generateWeeklyBrief(payload: { week_start?: string; week_end?: string } = {}) {
export interface WeeklyBriefWeekOption {
offset: number
label: string
week_start: string
week_end: string
}
export async function fetchWeeklyBriefWeekOptions() {
const { data } = await http.get<ApiBody<{ items: WeeklyBriefWeekOption[] }>>('/admin/v1/weekly-briefs/week-options')
return data.data.items
}
export async function generateWeeklyBrief(payload: {
week_start?: string
week_end?: string
week_offset?: number
} = {}) {
const { data } = await http.post<ApiBody<WeeklyBriefDetail>>('/admin/v1/weekly-briefs/generate', payload)
return data.data
}
export async function downloadWeeklyBriefDocx(id: number, filename: string) {
const res = await http.get(`/admin/v1/weekly-briefs/${id}/download`, {
responseType: 'blob',
})
const blob = new Blob([res.data], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}

@ -11,6 +11,10 @@ export interface DemandRow {
status_dict_item_id: number
status_item?: DictItemBrief | null
title: string
industry_type?: string | null
main_business?: string | null
finance_amount?: string | null
job_positions?: string | null
content: string
contact_name?: string | null
company?: string | null

@ -44,5 +44,7 @@ export function demandStatusClass(value?: string | null): string {
}
export function demandTypeClass(value?: string | null): string {
return value === 'tech' ? 'type-badge-brand' : 'type-badge-secondary'
if (value === 'finance') return 'type-badge-brand'
if (value === 'hire') return 'type-badge-info'
return 'type-badge-secondary'
}

@ -13,9 +13,12 @@ import {
type CrawlResolveResult,
fetchWeeklyBrief,
fetchWeeklyBriefs,
fetchWeeklyBriefWeekOptions,
generateWeeklyBrief,
downloadWeeklyBriefDocx,
type WeeklyBriefDetail,
type WeeklyBriefRow,
type WeeklyBriefWeekOption,
} from '@/api/admin/assets'
import { fetchDictByCode } from '@/api/admin/dict'
import {
@ -58,6 +61,8 @@ const briefMeta = ref({ current_page: 1, per_page: 10, total: 0 })
const briefPage = ref(1)
const briefDialog = ref(false)
const briefDetail = ref<WeeklyBriefDetail | null>(null)
const briefWeekOptions = ref<WeeklyBriefWeekOption[]>([])
const briefWeekOffset = ref(0)
const form = ref({
target_type: 'paper' as TargetType,
request_url: 'https://arxiv.org/',
@ -521,10 +526,21 @@ async function loadBriefs() {
}
}
async function loadBriefWeekOptions() {
try {
briefWeekOptions.value = await fetchWeeklyBriefWeekOptions()
if (briefWeekOptions.value.length && !briefWeekOptions.value.some((o) => o.offset === briefWeekOffset.value)) {
briefWeekOffset.value = briefWeekOptions.value[0].offset
}
} catch {
briefWeekOptions.value = []
}
}
async function onGenerateBrief() {
briefGenerating.value = true
try {
const brief = await generateWeeklyBrief()
const brief = await generateWeeklyBrief({ week_offset: briefWeekOffset.value })
ElMessage.success('周报已生成')
briefPage.value = 1
await loadBriefs()
@ -548,29 +564,34 @@ async function openBriefDetail(id: number) {
}
}
async function copyBriefMarkdown() {
if (!briefDetail.value?.markdown) return
async function copyBriefContent() {
const text = briefDetail.value?.content?.trim()
if (!text) {
ElMessage.warning('暂无简报正文,请重新生成')
return
}
try {
await navigator.clipboard.writeText(briefDetail.value.markdown)
ElMessage.success('已复制 Markdown')
await navigator.clipboard.writeText(text)
ElMessage.success('已复制简报内容')
} catch {
ElMessage.error('复制失败')
}
}
function downloadBriefMarkdown() {
if (!briefDetail.value?.markdown) return
const blob = new Blob([briefDetail.value.markdown], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `AI科技成果简报_${briefDetail.value.week_start}_${briefDetail.value.week_end}.md`
a.click()
URL.revokeObjectURL(url)
async function downloadBriefDocx(brief?: WeeklyBriefDetail | WeeklyBriefRow | null) {
if (!brief?.id) return
const filename = `高校科技成果周报_${brief.week_start}_${brief.week_end}.docx`
try {
await downloadWeeklyBriefDocx(brief.id, filename)
ElMessage.success('Word 简报已开始下载')
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '下载失败')
}
}
usePageLoad(async () => {
await resetPage()
await loadBriefWeekOptions()
await loadBriefs()
})
</script>
@ -740,12 +761,22 @@ usePageLoad(async () => {
<div>
<h3 class="brief-card-title">AI 科技成果周报</h3>
<p class="brief-card-desc">
基于爬虫入库的论文与资讯按周自动生成 Markdown 简报默认统计上一自然周周一至周日
基于爬虫入库的论文与资讯按周自动生成 Markdown 简报可选择本周截至今日或历史自然周
</p>
</div>
<el-button type="primary" :loading="briefGenerating" @click="onGenerateBrief">
生成上周简报
</el-button>
<div class="brief-generate-actions">
<el-select v-model="briefWeekOffset" class="brief-week-select" placeholder="选择统计周">
<el-option
v-for="opt in briefWeekOptions"
:key="opt.offset"
:label="`${opt.label}${opt.week_start} ~ ${opt.week_end}`"
:value="opt.offset"
/>
</el-select>
<el-button type="primary" :loading="briefGenerating" @click="onGenerateBrief">
生成简报
</el-button>
</div>
</div>
<el-table v-loading="briefLoading" :data="briefItems" row-key="id" size="small">
@ -762,9 +793,10 @@ usePageLoad(async () => {
<el-table-column label="生成时间" width="160">
<template #default="{ row }">{{ formatBriefGeneratedAt(row.generated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openBriefDetail(row.id)"></el-button>
<el-button type="primary" link :disabled="!row.has_docx" @click="downloadBriefDocx(row)"> Word</el-button>
</template>
</el-table-column>
</el-table>
@ -790,14 +822,23 @@ usePageLoad(async () => {
<div v-if="briefDetail" class="brief-dialog-meta">
<span>统计周期{{ formatWeekRange(briefDetail.week_start, briefDetail.week_end) }}</span>
<span>论文 {{ briefDetail.stats?.papers_count ?? 0 }} </span>
<span v-if="briefDetail.stats?.high_value_papers_count != null"> {{ briefDetail.stats.high_value_papers_count }} </span>
<span>资讯 {{ briefDetail.stats?.news_count ?? 0 }} </span>
<span>老师 {{ briefDetail.stats?.teachers_count ?? 0 }} </span>
</div>
<pre v-if="briefDetail" class="brief-markdown">{{ briefDetail.markdown }}</pre>
<el-input
v-if="briefDetail"
:model-value="briefDetail.content || '暂无简报正文,请重新生成'"
type="textarea"
:rows="18"
readonly
class="brief-content-input"
/>
<div v-else v-loading="true" class="brief-loading" />
<template #footer>
<el-button @click="briefDialog = false">关闭</el-button>
<el-button :disabled="!briefDetail" @click="copyBriefMarkdown"> Markdown</el-button>
<el-button type="primary" :disabled="!briefDetail" @click="downloadBriefMarkdown"> .md</el-button>
<el-button :disabled="!briefDetail?.content" @click="copyBriefContent"></el-button>
<el-button type="primary" :disabled="!briefDetail?.has_docx" @click="downloadBriefDocx(briefDetail)"> Word </el-button>
</template>
</el-dialog>
@ -939,6 +980,15 @@ usePageLoad(async () => {
gap: 16px;
margin-bottom: 16px;
}
.brief-generate-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.brief-week-select {
width: 320px;
}
.brief-card-title {
margin: 0 0 6px;
font-size: 16px;
@ -963,19 +1013,11 @@ usePageLoad(async () => {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.brief-markdown {
margin: 0;
padding: 16px;
max-height: 62vh;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
.brief-content-input :deep(textarea) {
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 13px;
line-height: 1.65;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
color: var(--el-text-color-primary);
}
.brief-loading {
min-height: 240px;

@ -191,7 +191,9 @@ usePageLoad(async () => {
</span>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="220" show-overflow-tooltip />
<el-table-column prop="title" label="项目名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="industry_type" label="行业类型" width="110" show-overflow-tooltip />
<el-table-column prop="main_business" label="主营业务" width="140" show-overflow-tooltip />
<el-table-column prop="contact_name" label="姓名" width="100" />
<el-table-column prop="company" label="公司" width="140" show-overflow-tooltip />
<el-table-column prop="submitted_at" label="时间" width="110" />
@ -239,6 +241,26 @@ usePageLoad(async () => {
<label class="field-label">需求类型</label>
<el-input :model-value="detail.type_item?.label || ''" readonly />
</el-col>
<el-col :span="6">
<label class="field-label">项目名称</label>
<el-input :model-value="detail.title || ''" readonly />
</el-col>
<el-col :span="6">
<label class="field-label">行业类型</label>
<el-input :model-value="detail.industry_type || ''" readonly />
</el-col>
<el-col :span="6">
<label class="field-label">主营业务</label>
<el-input :model-value="detail.main_business || ''" readonly />
</el-col>
<el-col v-if="detail.type_item?.value === 'finance'" :span="6">
<label class="field-label">金额</label>
<el-input :model-value="detail.finance_amount || ''" readonly />
</el-col>
<el-col v-if="detail.type_item?.value === 'hire'" :span="6">
<label class="field-label">岗位需求</label>
<el-input :model-value="detail.job_positions || ''" readonly />
</el-col>
<el-col :span="6">
<label class="field-label">提交人</label>
<el-input :model-value="detail.contact_name || ''" readonly />
@ -252,7 +274,7 @@ usePageLoad(async () => {
<el-input :model-value="detail.status_item?.label || ''" readonly />
</el-col>
<el-col :span="24">
<label class="field-label">需求描述</label>
<label class="field-label">简要描述</label>
<el-input :model-value="detail.content" type="textarea" :rows="3" readonly />
</el-col>
</el-row>
@ -299,7 +321,16 @@ usePageLoad(async () => {
</el-dialog>
<el-dialog v-model="followVisible" title="需求跟进" width="720px" destroy-on-close>
<div class="follow-teacher-summary">{{ followRow?.title }}</div>
<div v-if="followRow" class="follow-teacher-summary">
<div>{{ followRow.title }}</div>
<div class="follow-demand-meta">
<span>{{ followRow.type_item?.label }}</span>
<span v-if="followRow.industry_type"> · {{ followRow.industry_type }}</span>
<span v-if="followRow.main_business"> · {{ followRow.main_business }}</span>
<span v-if="followRow.type_item?.value === 'finance' && followRow.finance_amount"> · {{ followRow.finance_amount }}</span>
<span v-if="followRow.type_item?.value === 'hire' && followRow.job_positions"> · {{ followRow.job_positions }}</span>
</div>
</div>
<el-form label-position="top" class="form-small" style="margin-top: 12px">
<el-row :gutter="12">
<el-col :span="8">
@ -370,6 +401,12 @@ usePageLoad(async () => {
border-radius: 6px;
margin-bottom: 12px;
}
.follow-demand-meta {
margin-top: 6px;
font-size: 13px;
color: #6b7280;
line-height: 1.5;
}
.follow-history-list {
display: grid;
gap: 10px;

Loading…
Cancel
Save