shinnosuke
4 天以前 5ce0d76154bf4c1d70543476cdfad9a30195b0f8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
<template>
    <view class="page">
        <view class="nav-shell">
            <view class="safe-space" :style="'height:' + navTop + 'px'"></view>
            <view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'">
                <view class="nav-back" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view>
                <text class="nav-title">人才资讯</text>
                <view class="capsule-reserve" :style="'width:' + navWidth + 'px'"></view>
            </view>
            <view class="nav-bottom-space"></view>
        </view>
 
        <scroll-view :show-scrollbar="false" class="news-scroll" scroll-y lower-threshold="80" @scrolltolower="loadMoreNews">
            <view v-if="initialLoading" class="list-state"><view class="loading-spinner"></view><text>正在加载人才资讯</text></view>
            <view v-else-if="initialError" class="list-state error-state"><text>人才资讯加载失败</text><text class="retry-text" @tap="retryInitialLoad">重新加载</text></view>
            <view v-else-if="hasLoaded && visibleNews.length == 0" class="list-state"><text>暂无人才资讯</text></view>
            <view class="news-item" v-for="item in visibleNews" :key="item.id" @tap="openArticle(item.id)">
                <text class="news-title">{{item.title}}</text>
                <view class="news-body">
                    <view class="news-copy">
                        <text class="news-summary">{{item.summary}}</text>
                        <view class="news-meta"><text v-if="item.tag != ''" class="news-tag">{{item.tag}}</text><text class="news-time">{{item.time}}</text></view>
                    </view>
                    <!-- 缩略图用后台配置的封面;没配就留空(灰底占位),不再统一显示那张默认会议厅图。 -->
                    <image class="news-thumb" :src="item.image != null ? item.image : ''" mode="aspectFill" @error="replaceImage(item)"></image>
                </view>
            </view>
            <view v-if="loadingMore" class="loading-row"><view class="loading-spinner"></view><text>正在加载</text></view>
            <view v-else-if="loadMoreError" class="loading-row load-error" @tap="retryLoadMore"><text>加载失败,点击重试</text></view>
            <view v-else-if="hasLoaded && !hasMore && visibleNews.length > 0" class="no-more"><view></view><text>已经到底了</text><view></view></view>
            <view class="bottom-space"></view>
        </scroll-view>
    </view>
</template>
 
<script setup lang="uts">
    import { isApiConfigured } from '../../config/api.uts'
    import { getNews } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { safeBack } from '../../utils/navigation.uts'
 
    const navTop = ref(28)
    const navHeight = ref(32)
    const navWidth = ref(88)
    const navRight = ref(10)
    // A full first page avoids a non-scrollable four-item list on common phone viewports.
    const pageSize = 10
    const visibleNews = ref<any[]>([])
    const loadedCount = ref(0)
    const currentPage = ref(0)
    const initialLoading = ref(true)
    const loadingMore = ref(false)
    const hasLoaded = ref(false)
    const hasMore = ref(true)
    const initialError = ref(false)
    const loadMoreError = ref(false)
    const failInitialOnce = ref(false)
    const failLoadMoreOnce = ref(false)
    const forceEmpty = ref(false)
    // 封面图加载失败时置空,交给灰底占位,避免出现破图。
    const replaceImage = (item: any) => { item.image = '' }
 
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
        const capsule = uni.getMenuButtonBoundingClientRect()
        const systemInfo = uni.getSystemInfoSync()
        navTop.value = capsule.top
        navHeight.value = capsule.height
        navWidth.value = capsule.width
        navRight.value = systemInfo.windowWidth - capsule.right
        // #endif
        if (options != null && options['previewState'] != null) {
            const previewState = options['previewState'] as string
            failInitialOnce.value = previewState == 'initialError'
            failLoadMoreOnce.value = previewState == 'loadMoreError'
            forceEmpty.value = previewState == 'empty'
        }
        loadInitialNews()
    })
 
    const allNews = [
        { id: 'news-20260603-senior-talent', title: '攀枝花市东区事业单位2026年直接考核招聘副高职称及以上人才公告', summary: '根据《中共中央组织部 人力资源社会保障部关于进一步做好事业单位公开招聘工作的通知》《社会发展〔2024〕57号》...', time: '2026-06-03 09:19' },
        { id: 'news-20260601-score-review', title: '关于攀枝花市东区事业单位2026年上半年公开考试招聘工作人员东区岗位加分情况、笔试总成绩及岗位排名、面试资格复审有关事宜的公告', summary: '根据《关于攀枝花市事业单位2026年上半年公开考试招聘工作人员的公告》规定,现将本次东区岗位考生加分情况...', time: '2026-06-01 16:36' },
        { id: 'news-20260529-teacher-review', title: '攀枝花市2026年上半年公开考试招聘中小学教师东区岗位面试资格复审及领取面试通知书公告', summary: '根据《攀枝花市2026年上半年公开考试招聘中小学教师公告》规定,现就本次东区岗位面试资格复审、领取面试通知书...', time: '2026-05-29 10:42' },
        { id: 'news-20260522-medical-check', title: '关于攀枝花市东区2026年春季综合类、教育系统事业单位引才体检有关事宜的公告', summary: '根据《攀枝花市2026年春季综合类事业单位引才公告》《攀枝花市2026年春季教育系统事业单位引才公告》规定...', time: '2026-05-22 11:01' },
        { id: 'news-20260518-service', title: '东区人才综合服务窗口办事指南', summary: '为进一步提升人才服务质效,现将人才综合服务窗口地址、受理时间和办理事项公告如下...', time: '2026-05-18 09:30' },
        { id: 'news-20260512-policy', title: '2026年度东区人才政策申报工作提示', summary: '符合条件的人才可按照申报指南准备材料,并在规定时间内完成线上或线下提交...', time: '2026-05-12 14:20' },
        { id: 'news-20260508-training', title: '东区青年人才能力提升培训报名通知', summary: '本次培训围绕政策解读、创新创业和职业能力提升设置专题课程,欢迎符合条件的人才报名...', time: '2026-05-08 10:05' },
        { id: 'news-20260428-event', title: '东区人才交流活动安排公告', summary: '活动将组织人才项目交流、企业需求对接和人才服务咨询,具体安排如下...', time: '2026-04-28 16:10' }
    ]
    const formatNewsTime = (value: any): string => {
        if (value == null) return ''
        const text = '' + value
        return text.length >= 16 ? text.substring(0, 16).replace('T', ' ') : text.replace('T', ' ')
    }
    const contentPreview = (value: any): string => {
        if (value == null) return ''
        const plain = (value as string).replace(/\s+/g, ' ').trim()
        // 摘要区固定 3 行:按 3 行能放下的字数截断并补省略号,
        // 避免文字被容器高度从中间切断(只露出半行、又没有省略号)。
        return plain.length > 54 ? plain.substring(0, 54) + '...' : plain
    }
    // 标签只占一行:压掉换行/连续空格(text 组件会把 \n 渲染成换行),
    // 放不下时由样式的单行省略号收尾。
    const newsTagText = (value: any): string => {
        if (value == null) return ''
        return ('' + value).replace(/\s+/g, ' ').trim()
    }
    const mapNewsRecords = (records: any[]): any[] => {
        const mapped: any[] = []
        for (let index = 0; index < records.length; index++) {
            const item: any = records[index]
            mapped.push({
                id: '' + item.id,
                title: item.title != null ? item.title as string : '人才资讯',
                summary: contentPreview(item.contentPreview),
                tag: newsTagText(item.summary),
                time: formatNewsTime(item.publishTime),
                // 后台配置的封面图;相对路径补成接口域名,没配就留空走灰底占位。
                image: item.coverUrl != null && ('' + item.coverUrl).trim() != '' ? resolveApiUrl('' + item.coverUrl) : ''
            })
        }
        return mapped
    }
    const loadInitialNews = () => {
        initialLoading.value = true
        initialError.value = false
        loadMoreError.value = false
        hasLoaded.value = false
        visibleNews.value = []
        loadedCount.value = 0
        currentPage.value = 0
        if (isApiConfigured()) {
            getNews(1, pageSize, (data: any) => {
                const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []
                visibleNews.value = mapNewsRecords(records)
                loadedCount.value = records.length
                currentPage.value = 1
                hasMore.value = data != null && data.total != null ? records.length < data.total : false
                initialLoading.value = false
                hasLoaded.value = true
            }, () => {
                initialLoading.value = false
                initialError.value = true
            })
            return
        }
        setTimeout(() => {
            if (failInitialOnce.value) { failInitialOnce.value = false; initialLoading.value = false; initialError.value = true; return }
            const source = forceEmpty.value ? [] : allNews
            const end = Math.min(pageSize, source.length)
            const firstPage = []
            let index = 0
            while (index < end) { firstPage.push(source[index]); index++ }
            visibleNews.value = firstPage
            loadedCount.value = end
            hasMore.value = end < source.length
            initialLoading.value = false
            hasLoaded.value = true
        }, 450)
    }
    const retryInitialLoad = () => loadInitialNews()
    const retryLoadMore = () => loadMoreNews()
    const loadMoreNews = () => {
        if (initialLoading.value || loadingMore.value || !hasMore.value) return
        loadMoreError.value = false
        loadingMore.value = true
        if (isApiConfigured()) {
            getNews(currentPage.value + 1, pageSize, (data: any) => {
                const records: any[] = data != null && data.records instanceof Array ? data.records as any[] : []
                const next = visibleNews.value.slice()
                const mapped = mapNewsRecords(records)
                for (let index = 0; index < mapped.length; index++) next.push(mapped[index])
                visibleNews.value = next
                loadedCount.value = next.length
                currentPage.value = currentPage.value + 1
                hasMore.value = data != null && data.total != null ? next.length < data.total : false
                loadingMore.value = false
            }, () => {
                loadingMore.value = false
                loadMoreError.value = true
            })
            return
        }
        setTimeout(() => {
            if (failLoadMoreOnce.value) { failLoadMoreOnce.value = false; loadingMore.value = false; loadMoreError.value = true; return }
            const next = visibleNews.value.slice()
            const end = Math.min(loadedCount.value + pageSize, allNews.length)
            let index = loadedCount.value
            while (index < end) { next.push(allNews[index]); index++ }
            visibleNews.value = next
            loadedCount.value = end
            hasMore.value = end < allNews.length
            loadingMore.value = false
        }, 550)
    }
 
    const goBack = () => safeBack('/pages/index/index')
    const openArticle = (newsId: string) => uni.navigateTo({ url: '/pages/news-detail/news-detail?newsId=' + encodeURIComponent(newsId) })
</script>
 
<style>
    .page { display: flex; flex-direction: column; width: 100%; height: 100vh; background-color: #FFFFFF; }
    .nav-shell { flex-shrink: 0; background-image: linear-gradient(180deg, #FFF0B8 0%, #FFF8E4 58%, #FFFFFF 100%); }
    .safe-space { height: 28px; }
    .nav-row { display: flex; position: relative; height: 32px; box-sizing: border-box; align-items: center; justify-content: flex-end; }
    .nav-back { display: flex; position: absolute; z-index: 9; left: 16rpx; top: 0; bottom: 0; width: 88rpx; min-height: 64rpx; justify-content: center; align-items: center; }
    .nav-back image { width: 40rpx; height: 40rpx; }
    .nav-title { position: absolute; left: 190rpx; right: 190rpx; color: #171717; font-size: 35rpx; font-weight: 700; text-align: center; white-space: nowrap; }
    .capsule-reserve { width: 88px; height: 100%; flex-shrink: 0; }
    .nav-bottom-space { height: 34rpx; }
    .news-scroll { flex: 1; height: 0; background-color: #FFFFFF; }
    .list-state { display:flex; height:430rpx; flex-direction:row; justify-content:center; align-items:center; color:#A0A4AA; font-size:25rpx; }
    .error-state { flex-direction:column; }
    .retry-text { margin-top:22rpx; color:#F26A38; }
    .loading-row,.no-more { display:flex; height:90rpx; flex-direction:row; justify-content:center; align-items:center; color:#989DA5; font-size:22rpx; }
    .loading-spinner { width:28rpx; height:28rpx; margin-right:13rpx; box-sizing:border-box; border:3rpx solid #FFD6C4; border-top-color:#FF6C2B; border-radius:50%; animation:news-loading 720ms linear infinite; }
    .load-error { color:#F26A38; }
    .no-more view { width:70rpx; height:1rpx; background-color:#E7E7E7; }
    .no-more text { margin:0 18rpx; }
    @keyframes news-loading { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
    .news-item { padding: 0 28rpx 32rpx; margin-bottom: 8rpx; border-bottom-width: 1rpx; border-bottom-color: #F3F3F3; }
    .news-title { display: block; color: #252525; font-size: 30rpx; font-weight: 700; line-height: 41rpx; }
    .news-body { display: flex; height: 174rpx; margin-top: 13rpx; flex-direction: row; }
    .news-copy { display: flex; flex: 1; min-width: 0; padding-right: 18rpx; flex-direction: column; justify-content: space-between; }
    /* 摘要固定 3 行(3×34rpx=102rpx),超出用省略号,不再从文字中间截断。 */
    .news-summary { display: -webkit-box; height: 102rpx; overflow: hidden; -webkit-line-clamp: 3; -webkit-box-orient: vertical; color: #8E9299; font-size: 23rpx; line-height: 34rpx; }
    .news-meta { display: flex; height: 42rpx; min-width: 0; flex-direction: row; align-items: center; }
    /* 来源/摘要这行可能很长:按内容宽度排,放不下时收缩并省略,不去挤右侧时间。
       flex-shrink 必须显式写 1:uni-app x 的全局样式给 view/text 默认 flex-shrink: 0,
       不覆盖的话标签不会收缩,只会顶出卡片、把时间挤没(省略号也不会出现)。 */
    .news-tag { display: block; flex: 0 1 auto; flex-grow: 0; flex-shrink: 1; flex-basis: auto; min-width: 0; padding: 5rpx 9rpx; border-radius: 6rpx; background-color: #FFF8EA; color: #F1A32D; font-size: 19rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .news-time { margin-left: 13rpx; color: #A5A8AE; font-size: 20rpx; flex-shrink: 0; }
    /* 灰底占位:后台没配封面时不会出现破图或那张默认会议厅图。 */
    .news-thumb { width: 236rpx; height: 168rpx; border-radius: 7rpx; background-color: #EEEEEE; flex-shrink: 0; }
    .bottom-space { height: calc(28rpx + env(safe-area-inset-bottom)); }
</style>