|
|
|
|
|
<template>
|
|
|
|
|
|
<view class="page-wrap profile-bg crawler-page">
|
|
|
|
|
|
<view class="card form-card">
|
|
|
|
|
|
<view class="field">
|
|
|
|
|
|
<text class="form-label">入库类型 *</text>
|
|
|
|
|
|
<picker class="picker-field" :range="typeLabels" :value="typeIndex" @change="onTypeChange">
|
|
|
|
|
|
<view class="select">{{ typeLabels[typeIndex] }}</view>
|
|
|
|
|
|
</picker>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<view class="field">
|
|
|
|
|
|
<text class="form-label">目标地址 *</text>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="form.request_url"
|
|
|
|
|
|
class="input"
|
|
|
|
|
|
placeholder="https:// 列表页或详情页"
|
|
|
|
|
|
placeholder-class="input-placeholder"
|
|
|
|
|
|
@blur="onUrlBlur"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<text v-if="resolving" class="hint">正在识别采集源…</text>
|
|
|
|
|
|
<text v-else-if="resolvedName" class="hint">已识别:{{ resolvedName }}</text>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<view v-if="form.target_type !== 'teacher'" class="field">
|
|
|
|
|
|
<text class="form-label">搜索关键词(选填)</text>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
v-model="keyword"
|
|
|
|
|
|
class="textarea"
|
|
|
|
|
|
maxlength="500"
|
|
|
|
|
|
placeholder="多个关键词用逗号或换行分隔"
|
|
|
|
|
|
placeholder-class="input-placeholder"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<view v-if="form.target_type === 'teacher'" class="field">
|
|
|
|
|
|
<text class="form-label">搜索关键词(选填)</text>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
v-model="keyword"
|
|
|
|
|
|
class="textarea"
|
|
|
|
|
|
maxlength="500"
|
|
|
|
|
|
placeholder="多个关键词用空格、逗号或换行分隔"
|
|
|
|
|
|
placeholder-class="input-placeholder"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<view v-if="form.target_type === 'industry_news' || form.target_type === 'teacher'" class="field">
|
|
|
|
|
|
<text class="form-label">抓取页数</text>
|
|
|
|
|
|
<input v-model.number="maxPages" class="input" type="number" />
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<view class="field">
|
|
|
|
|
|
<text class="form-label">条数上限</text>
|
|
|
|
|
|
<input v-model.number="maxResults" class="input" type="number" />
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<button class="btn btn-primary submit-btn" :loading="submitting" @tap="submit">开始抓取</button>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
|
|
|
|
|
<view v-if="lastResult" class="card result-card">
|
|
|
|
|
|
<text class="result-title">抓取结果</text>
|
|
|
|
|
|
<text class="result-line">状态:{{ lastResult.status === 'completed' ? '已完成' : lastResult.status }}</text>
|
|
|
|
|
|
<text v-if="lastResult.source_name" class="result-line">采集源:{{ lastResult.source_name }}</text>
|
|
|
|
|
|
<text v-if="lastResult.result_summary" class="result-line">{{ lastResult.result_summary }}</text>
|
|
|
|
|
|
<text v-if="lastResult.result_summary" class="result-line result-summary">{{ lastResult.result_summary }}</text>
|
|
|
|
|
|
<text v-else class="result-line">已入库 {{ lastResult.items_imported ?? 0 }} 条</text>
|
|
|
|
|
|
<text v-if="lastResult.items_fetched" class="result-line">共抓取 {{ lastResult.items_fetched }} 条</text>
|
|
|
|
|
|
</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 { reactive, ref } from 'vue'
|
|
|
|
|
|
import { onShow } from '@dcloudio/uni-app'
|
|
|
|
|
|
import { crawlerApi, type CrawlTargetType } from '@/api/crawler'
|
|
|
|
|
|
import { useUserStore } from '@/stores/user'
|
|
|
|
|
|
|
|
|
|
|
|
const userStore = useUserStore()
|
|
|
|
|
|
const typeOptions: { label: string; value: CrawlTargetType }[] = [
|
|
|
|
|
|
{ label: '论文 → 论文库', value: 'paper' },
|
|
|
|
|
|
{ label: '行业资讯 → 资讯管理', value: 'industry_news' },
|
|
|
|
|
|
{ label: '老师库 → 老师库', value: 'teacher' },
|
|
|
|
|
|
]
|
|
|
|
|
|
const typeLabels = typeOptions.map((item) => item.label)
|
|
|
|
|
|
const typeIndex = ref(0)
|
|
|
|
|
|
const keyword = ref('')
|
|
|
|
|
|
const maxPages = ref(5)
|
|
|
|
|
|
const maxResults = ref(20)
|
|
|
|
|
|
const resolving = ref(false)
|
|
|
|
|
|
const submitting = ref(false)
|
|
|
|
|
|
const resolvedName = ref('')
|
|
|
|
|
|
const lastResult = ref<Awaited<ReturnType<typeof crawlerApi.submit>> | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
const form = reactive({
|
|
|
|
|
|
target_type: 'paper' as CrawlTargetType,
|
|
|
|
|
|
request_url: '',
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
function onTypeChange(event: UniHelper.PickerChangeEvent) {
|
|
|
|
|
|
typeIndex.value = Number(event.detail.value)
|
|
|
|
|
|
form.target_type = typeOptions[typeIndex.value]?.value || 'paper'
|
|
|
|
|
|
resolvedName.value = ''
|
|
|
|
|
|
if (form.target_type === 'teacher') {
|
|
|
|
|
|
maxResults.value = 100
|
|
|
|
|
|
} else if (form.target_type === 'industry_news') {
|
|
|
|
|
|
maxResults.value = 30
|
|
|
|
|
|
} else {
|
|
|
|
|
|
maxResults.value = 20
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onUrlBlur() {
|
|
|
|
|
|
const url = form.request_url.trim()
|
|
|
|
|
|
if (!url) {
|
|
|
|
|
|
resolvedName.value = ''
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
resolving.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await crawlerApi.resolveUrl({
|
|
|
|
|
|
request_url: url.startsWith('http') ? url : `https://${url}`,
|
|
|
|
|
|
target_type: form.target_type,
|
|
|
|
|
|
})
|
|
|
|
|
|
resolvedName.value = res.source_name
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
resolvedName.value = ''
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: error instanceof Error ? error.message : '无法识别该地址',
|
|
|
|
|
|
icon: 'none',
|
|
|
|
|
|
})
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
resolving.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildParams(): Record<string, unknown> {
|
|
|
|
|
|
const params: Record<string, unknown> = {}
|
|
|
|
|
|
if (keyword.value.trim()) {
|
|
|
|
|
|
params.keyword = keyword.value.trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
params.max_results = maxResults.value
|
|
|
|
|
|
if (form.target_type === 'industry_news' || form.target_type === 'teacher') {
|
|
|
|
|
|
params.max_pages = maxPages.value
|
|
|
|
|
|
}
|
|
|
|
|
|
return params
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildSuccessMessage(result: Awaited<ReturnType<typeof crawlerApi.submit>>): string {
|
|
|
|
|
|
if (result.target_type === 'paper') {
|
|
|
|
|
|
const papers = result.papers_imported ?? result.items_imported ?? 0
|
|
|
|
|
|
const leads = result.teacher_leads_imported ?? 0
|
|
|
|
|
|
return `已入库 ${papers} 篇论文、${leads} 位作者`
|
|
|
|
|
|
}
|
|
|
|
|
|
return `已入库 ${result.items_imported ?? 0} 条`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submit() {
|
|
|
|
|
|
const url = form.request_url.trim()
|
|
|
|
|
|
if (!url) {
|
|
|
|
|
|
uni.showToast({ title: '请填写目标地址', icon: 'none' })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
submitting.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
lastResult.value = await crawlerApi.submit({
|
|
|
|
|
|
target_type: form.target_type,
|
|
|
|
|
|
request_url: url.startsWith('http') ? url : `https://${url}`,
|
|
|
|
|
|
params: buildParams(),
|
|
|
|
|
|
})
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: buildSuccessMessage(lastResult.value),
|
|
|
|
|
|
icon: 'success',
|
|
|
|
|
|
})
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
uni.showToast({
|
|
|
|
|
|
title: error instanceof Error ? error.message : '抓取失败',
|
|
|
|
|
|
icon: 'none',
|
|
|
|
|
|
})
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
submitting.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
@import '@/styles/page.scss';
|
|
|
|
|
|
@import '@/styles/card.scss';
|
|
|
|
|
|
@import '@/styles/tokens.scss';
|
|
|
|
|
|
|
|
|
|
|
|
.crawler-page {
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-card,
|
|
|
|
|
|
.result-card {
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
margin-bottom: $section-gap;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-card {
|
|
|
|
|
|
padding: 32rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.result-card {
|
|
|
|
|
|
padding: 32rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.field {
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
margin-bottom: 24rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.field:last-of-type {
|
|
|
|
|
|
margin-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-label {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
margin-bottom: 12rpx;
|
|
|
|
|
|
color: #374151;
|
|
|
|
|
|
font-size: 28rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.picker-field {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.select,
|
|
|
|
|
|
.input,
|
|
|
|
|
|
.textarea {
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.select {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
min-height: 84rpx;
|
|
|
|
|
|
padding: 0 24rpx;
|
|
|
|
|
|
border: 1px solid #d6dde8;
|
|
|
|
|
|
border-radius: 16rpx;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
font-size: 28rpx;
|
|
|
|
|
|
line-height: 1.4;
|
|
|
|
|
|
color: #111827;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.input {
|
|
|
|
|
|
height: 84rpx;
|
|
|
|
|
|
padding: 0 24rpx;
|
|
|
|
|
|
border: 1px solid #d6dde8;
|
|
|
|
|
|
border-radius: 16rpx;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
font-size: 28rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.textarea {
|
|
|
|
|
|
min-height: 240rpx;
|
|
|
|
|
|
padding: 24rpx;
|
|
|
|
|
|
border: 1px solid #d6dde8;
|
|
|
|
|
|
border-radius: 16rpx;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
font-size: 28rpx;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.input-placeholder {
|
|
|
|
|
|
color: #9ca3af;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hint {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
margin-top: 12rpx;
|
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
|
font-size: 24rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.submit-btn {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
min-height: 88rpx;
|
|
|
|
|
|
margin-top: 32rpx;
|
|
|
|
|
|
border-radius: 16rpx;
|
|
|
|
|
|
font-size: 30rpx;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.submit-btn::after {
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.result-title {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
margin-bottom: 16rpx;
|
|
|
|
|
|
color: #111827;
|
|
|
|
|
|
font-size: 30rpx;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.result-line {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
margin-top: 8rpx;
|
|
|
|
|
|
color: #4b5563;
|
|
|
|
|
|
font-size: 26rpx;
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.result-summary {
|
|
|
|
|
|
margin-top: 8rpx;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|