feat:首页和案例库功能

This commit is contained in:
李伊帆
2026-09-21 15:41:52 +08:00
parent 24d001e0f5
commit b0a711f70a
35 changed files with 1201 additions and 354 deletions

10
src/api/banner.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { BannerItem } from '@/types/banner'
import { get } from '@/utils/request'
const BANNER_API = {
list: '/app/banner/list',
}
export function getBannerList() {
return get<BannerItem[]>(BANNER_API.list)
}

45
src/api/case-library.ts Normal file
View File

@@ -0,0 +1,45 @@
import type {
CaseLibraryFavoriteListQuery,
CaseLibraryListQuery,
CaseLibraryPageResult,
ProjectCaseVo,
} from '@/types/case-library-api'
import { get, post } from '@/utils/request'
const CASE_LIBRARY_API = {
list: '/app/caseLibrary/list',
detail: (id: string | number) => `/app/caseLibrary/${id}`,
industries: '/app/caseLibrary/industries',
favoriteList: '/app/caseLibrary/favorite/list',
favorite: (id: string | number) => `/app/caseLibrary/${id}/favorite`,
unfavorite: (id: string | number) => `/app/caseLibrary/${id}/unfavorite`,
}
export function getCaseLibraryList(params: CaseLibraryListQuery) {
return get<CaseLibraryPageResult>(CASE_LIBRARY_API.list, params)
}
/** 查询案例详情:GET /app/caseLibrary/{id} */
export function getCaseLibraryDetail(id: string | number) {
return get<ProjectCaseVo>(CASE_LIBRARY_API.detail(id))
}
/** 案例库行业选项:GET /app/caseLibrary/industries */
export function getCaseLibraryIndustries() {
return get<string[]>(CASE_LIBRARY_API.industries, undefined, { showErrorToast: false })
}
/** 我的收藏列表:GET /app/caseLibrary/favorite/list */
export function getCaseLibraryFavoriteList(params: CaseLibraryFavoriteListQuery) {
return get<CaseLibraryPageResult>(CASE_LIBRARY_API.favoriteList, params)
}
/** 收藏案例:POST /app/caseLibrary/{id}/favorite */
export function favoriteCase(id: string | number) {
return post<void>(CASE_LIBRARY_API.favorite(id), {})
}
/** 取消收藏:POST /app/caseLibrary/{id}/unfavorite */
export function unfavoriteCase(id: string | number) {
return post<void>(CASE_LIBRARY_API.unfavorite(id), {})
}

14
src/api/oss.ts Normal file
View File

@@ -0,0 +1,14 @@
export interface OssFileItem {
ossId?: number | string
url?: string
}
export function getOssListByIds(ossIds: Array<string | number>) {
const ids = [...new Set(ossIds.map(id => String(id).trim()).filter(Boolean))]
if (!ids.length)
return Promise.resolve([] as OssFileItem[])
return get<OssFileItem[]>(`/resource/oss/listByIds/${ids.join(',')}`, undefined, {
showErrorToast: false,
})
}

View File

@@ -0,0 +1,16 @@
import type { ProjectSubmissionItem } from '@/types/project-submission'
import { get } from '@/utils/request'
const PROJECT_SUBMISSION_API = {
list: '/app/projectSubmission/list',
detail: (id: string | number) => `/app/projectSubmission/${id}`,
}
export function getProjectSubmissionList() {
return get<ProjectSubmissionItem[]>(PROJECT_SUBMISSION_API.list)
}
/** 查询 App 项目提报详情:GET /app/projectSubmission/{id} */
export function getProjectSubmissionDetail(id: string | number) {
return get<ProjectSubmissionItem>(PROJECT_SUBMISSION_API.detail(id))
}

View File

@@ -1,34 +0,0 @@
import { CASE_LIBRARY_LIST } from '@/constants/case-library'
import type { CaseDetail } from '@/types/case-library'
const SHARED_BACKGROUND = '随着我国城镇化进程不断加快,园区管理面临着从传统粗放式管理向精细化、智能化管理转型的迫切需求。在"数字中国"战略背景下,各地园区纷纷探索通过信息化手段提升运营效率、优化资源配置、改善企业服务体验。'
const SHARED_PAIN_POINTS = '传统园区管理方式依赖人工巡检和纸质记录,信息孤岛严重,各部门数据无法互通,导致管理效率低下。企业服务响应慢,缺乏统一的服务入口,企业诉求难以快速传达和处理。能耗管理粗放,无法实时监控和分析,造成资源浪费。'
const SHARED_REQUIREMENT = '建设一套集园区管理、企业服务、数据分析于一体的综合管理平台,实现园区运营的数字化、智能化。平台需支持多端访问,包括Web管理端、移动端APP,满足不同角色的使用需求。'
const SHARED_DELIVERABLES = '交付园区综合管理平台一套,包含Web管理端、移动端APP、数据可视化大屏,并提供完整的源代码、技术文档、用户手册及一年免费运维服务。'
const DEFAULT_ATTACHMENTS = [
{ id: '1', name: '需求文档.pdf', icon: '/static/icons/PDF.png' },
{ id: '2', name: '系统....pdf', icon: '/static/icons/PDF.png' },
{ id: '3', name: '参考图.png', icon: '/static/icons/PNG.png' },
]
const CASE_DETAIL_MAP: Record<string, CaseDetail> = Object.fromEntries(
CASE_LIBRARY_LIST.map(item => [
item.id,
{
...item,
background: SHARED_BACKGROUND,
painPoints: SHARED_PAIN_POINTS,
requirement: SHARED_REQUIREMENT,
deliverables: SHARED_DELIVERABLES,
attachments: DEFAULT_ATTACHMENTS,
},
]),
)
export function getCaseDetail(id: string): CaseDetail | null {
return CASE_DETAIL_MAP[id] || null
}

View File

@@ -1,4 +1,4 @@
import type { CaseCategory, CaseFilterValues, CaseItem } from '@/types/case-library' import type { CaseCategory, CaseFilterValues } from '@/types/case-library'
export const CASE_CATEGORIES: CaseCategory[] = [ export const CASE_CATEGORIES: CaseCategory[] = [
'全部', '全部',
@@ -10,17 +10,11 @@ export const CASE_CATEGORIES: CaseCategory[] = [
export const CASE_PAGE_SIZE = 4 export const CASE_PAGE_SIZE = 4
export const CASE_TOTAL_COUNT = 6 /** 收藏列表默认按完成时间倒序 */
export const CASE_FAVORITE_LIST_SORT = {
export const CASE_FILTER_INDUSTRIES = [ orderByColumn: 'completedAt',
'金融科技', isAsc: 'desc',
'电商零售', } as const
'教育培训',
'医疗健康',
'硬件电路',
'能源环保',
'房地产',
] as const
export const CASE_FILTER_SCALES = [ export const CASE_FILTER_SCALES = [
'全部', '全部',
@@ -47,80 +41,3 @@ export const DEFAULT_CASE_FILTER: CaseFilterValues = {
end: '', end: '',
}, },
} }
export const CASE_LIBRARY_LIST: CaseItem[] = [
{
id: '1',
title: '智能园区综合管理平台开发',
category: '互联网软件',
cover: '/static/icons/background.png',
clientType: '企业',
industry: '房地产',
scale: '200万',
cycle: '200个工作日',
teamSize: '20人',
completedAt: '2026/8/10',
favorite: true,
},
{
id: '2',
title: '工业设备远程监控系统',
category: '物联网全案',
cover: '/static/icons/1.png',
clientType: '企业',
industry: '金融科技',
scale: '150万',
cycle: '180个工作日',
teamSize: '16人',
completedAt: '2026/7/22',
},
{
id: '3',
title: '社区康养智能服务系统',
category: '互联网软件',
cover: '/static/icons/software.png',
clientType: '政府',
industry: '医疗健康',
scale: '320万',
cycle: '240个工作日',
teamSize: '24人',
completedAt: '2026/6/15',
favorite: true,
},
{
id: '4',
title: '新能源充电桩控制主板设计',
category: '硬件电路',
cover: '/static/icons/hardware.png',
clientType: '企业',
industry: '能源环保',
scale: '80万',
cycle: '90个工作日',
teamSize: '8人',
completedAt: '2026/5/28',
},
{
id: '5',
title: '智慧农业环境监测平台',
category: '物联网全案',
cover: '/static/icons/iot.png',
clientType: '企业',
industry: '教育培训',
scale: '260万',
cycle: '210个工作日',
teamSize: '18人',
completedAt: '2026/4/12',
},
{
id: '6',
title: '智能穿戴设备批量生产项目',
category: '生产制造',
cover: '/static/icons/production.png',
clientType: '企业',
industry: '电商零售',
scale: '500万',
cycle: '300个工作日',
teamSize: '30人',
completedAt: '2026/3/8',
},
]

View File

@@ -1,73 +1,24 @@
import { computed, ref } from 'vue' import { favoriteCase, unfavoriteCase } from '@/api/case-library'
import { CASE_LIBRARY_LIST } from '@/constants/case-library' import { ensureLoggedIn } from '@/utils/auth-guard'
import type { CaseItem } from '@/types/case-library'
export const CASE_FAVORITES_STORAGE_KEY = 'case-favorites'
function getDefaultFavoriteIds(): string[] {
return CASE_LIBRARY_LIST
.filter(item => item.favorite)
.map(item => item.id)
}
function loadFavoriteIds(): string[] {
try {
const stored = uni.getStorageSync(CASE_FAVORITES_STORAGE_KEY)
if (Array.isArray(stored))
return stored
}
catch {
// ignore storage read errors
}
return getDefaultFavoriteIds()
}
function saveFavoriteIds(ids: string[]) {
uni.setStorageSync(CASE_FAVORITES_STORAGE_KEY, ids)
}
const favoriteIds = ref<string[]>(loadFavoriteIds())
export function useCaseFavorites() { export function useCaseFavorites() {
const favoriteCases = computed(() => async function toggleFavorite(id: string, currentFavorite: boolean) {
CASE_LIBRARY_LIST if (!ensureLoggedIn())
.filter(item => favoriteIds.value.includes(item.id)) return false
.map(item => ({ ...item, favorite: true })),
)
function syncFavoriteIds() { try {
favoriteIds.value = loadFavoriteIds() if (currentFavorite)
await unfavoriteCase(id)
else
await favoriteCase(id)
return true
} }
catch {
function isFavorite(id: string) { return false
return favoriteIds.value.includes(id)
} }
function toggleFavorite(id: string) {
if (favoriteIds.value.includes(id)) {
favoriteIds.value = favoriteIds.value.filter(itemId => itemId !== id)
}
else {
favoriteIds.value = [...favoriteIds.value, id]
}
saveFavoriteIds(favoriteIds.value)
}
function applyFavoritesToList(list: CaseItem[]): CaseItem[] {
return list.map(item => ({
...item,
favorite: favoriteIds.value.includes(item.id),
}))
} }
return { return {
favoriteIds,
favoriteCases,
syncFavoriteIds,
isFavorite,
toggleFavorite, toggleFavorite,
applyFavoritesToList,
} }
} }

View File

@@ -0,0 +1,34 @@
import { ref } from 'vue'
import { getCaseLibraryIndustries } from '@/api/case-library'
const industries = ref<string[]>([])
let loading = false
let loaded = false
export function useCaseIndustries() {
async function loadCaseIndustries(force = false) {
if ((loaded || loading) && !force)
return industries.value
loading = true
try {
const data = await getCaseLibraryIndustries()
industries.value = Array.isArray(data) ? data.filter(Boolean) : []
loaded = true
}
catch {
if (!industries.value.length)
industries.value = []
}
finally {
loading = false
}
return industries.value
}
return {
industries,
loadCaseIndustries,
}
}

View File

@@ -55,6 +55,12 @@
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{
"path": "pages/home-index/project-submission-detail/index",
"style": {
"navigationStyle": "custom"
}
},
{ {
"path": "pages/home-index/requirement-submit/index", "path": "pages/home-index/requirement-submit/index",
"style": { "style": {

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { loginByPassword } from '@/api/auth' import { loginByPassword } from '@/api/auth'
import { setAccessToken, setLoginPhoneNumber } from '@/utils/auth-storage' import { saveAuthSession, setLoginPhoneNumber } from '@/utils/auth-storage'
import { navigateToResultPage } from '@/utils/navigate-result' import { navigateToResultPage } from '@/utils/navigate-result'
import { validatePhone } from '@/utils/sms-code' import { validatePhone } from '@/utils/sms-code'
import LoginInput from './components/LoginInput.vue' import LoginInput from './components/LoginInput.vue'
@@ -44,7 +44,7 @@ async function handleLogin() {
}, { showErrorToast: false }) }, { showErrorToast: false })
if (result.access_token) if (result.access_token)
setAccessToken(result.access_token) saveAuthSession(result.access_token, result.client_id)
setLoginPhoneNumber(phone.value.trim()) setLoginPhoneNumber(phone.value.trim())

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { loginBySms } from '@/api/auth' import { loginBySms } from '@/api/auth'
import { setAccessToken, setLoginPhoneNumber } from '@/utils/auth-storage' import { saveAuthSession, setLoginPhoneNumber } from '@/utils/auth-storage'
import { navigateBackWithFallback } from '@/utils/navigate-back' import { navigateBackWithFallback } from '@/utils/navigate-back'
import { navigateToResultPage } from '@/utils/navigate-result' import { navigateToResultPage } from '@/utils/navigate-result'
import { validatePhone } from '@/utils/sms-code' import { validatePhone } from '@/utils/sms-code'
@@ -46,7 +46,7 @@ async function handleLogin() {
}, { showErrorToast: false }) }, { showErrorToast: false })
if (result.access_token) if (result.access_token)
setAccessToken(result.access_token) saveAuthSession(result.access_token, result.client_id)
setLoginPhoneNumber(phone.value.trim()) setLoginPhoneNumber(phone.value.trim())

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { import {
CASE_FILTER_INDUSTRIES,
CASE_FILTER_SCALES, CASE_FILTER_SCALES,
CASE_FILTER_TIME_RANGES, CASE_FILTER_TIME_RANGES,
DEFAULT_CASE_FILTER, DEFAULT_CASE_FILTER,
@@ -20,6 +19,7 @@ const props = defineProps<{
visible: boolean visible: boolean
cases: CaseFilterTarget[] cases: CaseFilterTarget[]
value: CaseFilterValues value: CaseFilterValues
industries: string[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -185,7 +185,7 @@ function handleConfirm() {
</text> </text>
<view class="case-filter-panel__chips"> <view class="case-filter-panel__chips">
<text <text
v-for="item in CASE_FILTER_INDUSTRIES" v-for="item in industries"
:key="item" :key="item"
class="case-filter-panel__chip" class="case-filter-panel__chip"
:class="{ 'case-filter-panel__chip--active': isIndustryActive(item) }" :class="{ 'case-filter-panel__chip--active': isIndustryActive(item) }"

View File

@@ -115,13 +115,21 @@
color: $color-text-primary; color: $color-text-primary;
} }
.case-detail__image-placeholder { .case-detail__cover {
width: 100%;
height: 280rpx; height: 280rpx;
margin-bottom: 24rpx; margin-bottom: 24rpx;
background: #e8eaed; background: #e8eaed;
border-radius: 12rpx; border-radius: 12rpx;
} }
.case-detail__rich-text {
display: block;
font-size: 28rpx;
line-height: 1.7;
color: #666666;
}
.case-detail__paragraph { .case-detail__paragraph {
display: block; display: block;
font-size: 28rpx; font-size: 28rpx;

View File

@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { getCaseDetail } from '@/constants/case-detail' import { getCaseLibraryDetail } from '@/api/case-library'
import type { CaseDetail } from '@/types/case-library' import type { CaseDetail } from '@/types/case-library'
import { mapProjectCaseToCaseDetail } from '@/utils/map-case-library'
import { navigateBackWithFallback } from '@/utils/navigate-back' import { navigateBackWithFallback } from '@/utils/navigate-back'
import deleteIcon from '@/static/icons/X.png'
const detail = ref<CaseDetail | null>(null) const detail = ref<CaseDetail | null>(null)
const loading = ref(true)
const overviewItems = [ const overviewItems = [
{ label: '客户类型', key: 'clientType' as const }, { label: '客户类型', key: 'clientType' as const },
@@ -17,20 +18,47 @@ const overviewItems = [
{ label: '完成时间', key: 'completedAt' as const }, { label: '完成时间', key: 'completedAt' as const },
] ]
async function loadDetail(id: string) {
loading.value = true
uni.showLoading({ title: '加载中...', mask: true })
try {
const data = await getCaseLibraryDetail(id)
detail.value = await mapProjectCaseToCaseDetail(data)
}
catch {
detail.value = null
uni.showToast({ title: '加载失败', icon: 'none' })
setTimeout(() => {
navigateBackWithFallback('/pages/case-library/index')
}, 1500)
}
finally {
loading.value = false
uni.hideLoading()
}
}
onLoad((options) => { onLoad((options) => {
const rawId = options?.id const rawId = options?.id
const id = rawId ? decodeURIComponent(String(rawId)) : '1' const id = rawId ? decodeURIComponent(String(rawId)) : ''
detail.value = getCaseDetail(id) if (!id) {
loading.value = false
if (!detail.value) { uni.showToast({ title: '参数错误', icon: 'none' })
uni.showToast({ title: '案例不存在', icon: 'none' }) return
setTimeout(() => uni.navigateBack(), 1500)
} }
loadDetail(id)
}) })
function handleBack() { function handleBack() {
navigateBackWithFallback('/pages/case-library/index') navigateBackWithFallback('/pages/case-library/index')
} }
function handleOpenAttachment(url?: string) {
if (!url)
return
uni.previewImage({ urls: [url] })
}
</script> </script>
<template> <template>
@@ -90,40 +118,40 @@ function handleBack() {
</view> </view>
</view> </view>
<view class="case-detail__section"> <view
v-for="(section, index) in detail.catalogs"
:key="section.id"
class="case-detail__section"
:class="{ 'case-detail__section--last': index === detail.catalogs.length - 1 && !detail.attachments.length }"
>
<text class="case-detail__section-title"> <text class="case-detail__section-title">
项目背景 {{ section.title }}
</text> </text>
<view class="case-detail__image-placeholder" /> <image
<text class="case-detail__paragraph"> v-if="index === 0 && detail.cover"
{{ detail.background }} class="case-detail__cover"
:src="detail.cover"
mode="aspectFill"
/>
<rich-text
v-if="section.content"
class="case-detail__rich-text"
:nodes="section.content"
/>
<text
v-else
class="case-detail__paragraph"
>
—
</text> </text>
</view> </view>
<view class="case-detail__section"> <view
v-if="detail.attachments.length"
class="case-detail__section case-detail__section--last"
>
<text class="case-detail__section-title"> <text class="case-detail__section-title">
客户痛点 附件
</text>
<text class="case-detail__paragraph">
{{ detail.painPoints }}
</text>
</view>
<view class="case-detail__section">
<text class="case-detail__section-title">
需求描述
</text>
<text class="case-detail__paragraph">
{{ detail.requirement }}
</text>
</view>
<view class="case-detail__section case-detail__section--last">
<text class="case-detail__section-title">
交付成果
</text>
<text class="case-detail__paragraph">
{{ detail.deliverables }}
</text> </text>
<scroll-view <scroll-view
class="case-detail__attachments" class="case-detail__attachments"
@@ -135,14 +163,8 @@ function handleBack() {
v-for="file in detail.attachments" v-for="file in detail.attachments"
:key="file.id" :key="file.id"
class="case-detail__attachment" class="case-detail__attachment"
@tap="handleOpenAttachment(file.url)"
> >
<view class="case-detail__attachment-remove">
<image
class="case-detail__attachment-remove-icon"
:src="deleteIcon"
mode="aspectFit"
/>
</view>
<image <image
class="case-detail__attachment-icon" class="case-detail__attachment-icon"
:src="file.icon" :src="file.icon"
@@ -158,8 +180,29 @@ function handleBack() {
</view> </view>
</scroll-view> </scroll-view>
</view> </view>
<view
v-else-if="!loading"
class="case-detail case-detail--empty"
>
<text class="case-detail__empty-text">
暂无详情
</text>
</view>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import './index.scss'; @import './index.scss';
.case-detail--empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.case-detail__empty-text {
font-size: 28rpx;
color: #999999;
}
</style> </style>

View File

@@ -1,31 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { getCaseLibraryFavoriteList } from '@/api/case-library'
import { import {
CASE_CATEGORIES, CASE_CATEGORIES,
CASE_FILTER_INDUSTRIES, CASE_FAVORITE_LIST_SORT,
CASE_FILTER_SCALES, CASE_FILTER_SCALES,
CASE_FILTER_TIME_RANGES, CASE_FILTER_TIME_RANGES,
CASE_PAGE_SIZE, CASE_PAGE_SIZE,
DEFAULT_CASE_FILTER, DEFAULT_CASE_FILTER,
} from '@/constants/case-library' } from '@/constants/case-library'
import { useCaseFavorites } from '@/hooks/use-case-favorites' import { useCaseFavorites } from '@/hooks/use-case-favorites'
import type { CaseCategory, CaseFilterValues } from '@/types/case-library' import { useCaseIndustries } from '@/hooks/use-case-industries'
import type { CaseCategory, CaseFilterValues, CaseItem } from '@/types/case-library'
import filterIcon from '@/static/icons/icon-filter.svg' import filterIcon from '@/static/icons/icon-filter.svg'
import { ensureLoggedIn } from '@/utils/auth-guard'
import { navigateBackWithFallback } from '@/utils/navigate-back' import { navigateBackWithFallback } from '@/utils/navigate-back'
import { cloneCaseFilter, matchCaseFilter } from '@/utils/case-filter' import { cloneCaseFilter, matchCaseFilter } from '@/utils/case-filter'
import { mapProjectCasesToCaseItems } from '@/utils/map-case-library'
import CaseCard from '../components/CaseCard.vue' import CaseCard from '../components/CaseCard.vue'
import CaseFilterPanel from '../components/CaseFilterPanel.vue' import CaseFilterPanel from '../components/CaseFilterPanel.vue'
import LoadMoreFooter from '../components/LoadMoreFooter.vue' import LoadMoreFooter from '../components/LoadMoreFooter.vue'
const LOAD_MORE_DELAY = 600 const { toggleFavorite } = useCaseFavorites()
const { industries, loadCaseIndustries } = useCaseIndustries()
const { favoriteCases, syncFavoriteIds, toggleFavorite } = useCaseFavorites()
const keyword = ref('') const keyword = ref('')
const activeCategory = ref<CaseCategory>('全部') const activeCategory = ref<CaseCategory>('全部')
const caseList = ref(favoriteCases.value.map(item => ({ ...item }))) const caseList = ref<CaseItem[]>([])
const pageSize = ref(CASE_PAGE_SIZE) const totalCount = ref(0)
const pageNum = ref(1)
const loadingList = ref(false)
const loadingMore = ref(false) const loadingMore = ref(false)
const filterVisible = ref(false) const filterVisible = ref(false)
const appliedFilter = ref<CaseFilterValues>(cloneCaseFilter(DEFAULT_CASE_FILTER)) const appliedFilter = ref<CaseFilterValues>(cloneCaseFilter(DEFAULT_CASE_FILTER))
@@ -41,19 +46,57 @@ const filteredCases = computed(() => {
}) })
}) })
const visibleCases = computed(() => filteredCases.value.slice(0, pageSize.value)) const hasMore = computed(() => caseList.value.length < totalCount.value)
const loadFinished = computed(() => !hasMore.value && caseList.value.length > 0)
const hasMore = computed(() => pageSize.value < filteredCases.value.length) async function fetchFavoriteList(reset = false) {
if (reset) {
pageNum.value = 1
caseList.value = []
}
const loadFinished = computed(() => !hasMore.value && visibleCases.value.length > 0) if (loadingList.value || loadingMore.value)
return
watch([keyword, activeCategory, appliedFilter], () => { if (reset)
pageSize.value = CASE_PAGE_SIZE loadingList.value = true
else
loadingMore.value = true
try {
const result = await getCaseLibraryFavoriteList({
pageNum: pageNum.value,
pageSize: CASE_PAGE_SIZE,
orderByColumn: CASE_FAVORITE_LIST_SORT.orderByColumn,
isAsc: CASE_FAVORITE_LIST_SORT.isAsc,
}) })
const rows = result?.rows ?? []
const mapped = await mapProjectCasesToCaseItems(rows)
const favorites = mapped.map(item => ({ ...item, favorite: true }))
totalCount.value = Number(result?.total ?? 0)
caseList.value = reset
? favorites
: [...caseList.value, ...favorites]
}
catch {
if (reset) {
caseList.value = []
totalCount.value = 0
}
}
finally {
loadingList.value = false
loadingMore.value = false
}
}
onShow(() => { onShow(() => {
syncFavoriteIds() if (!ensureLoggedIn())
caseList.value = favoriteCases.value.map(item => ({ ...item })) return
loadCaseIndustries()
fetchFavoriteList(true)
}) })
function handleBack() { function handleBack() {
@@ -64,21 +107,21 @@ function handleSelectCategory(category: CaseCategory) {
activeCategory.value = category activeCategory.value = category
} }
function handleToggleFavorite(id: string) { async function handleToggleFavorite(id: string) {
toggleFavorite(id) const success = await toggleFavorite(id, true)
caseList.value = favoriteCases.value.map(item => ({ ...item })) if (!success)
return
caseList.value = caseList.value.filter(item => item.id !== id)
totalCount.value = Math.max(0, totalCount.value - 1)
} }
function handleLoadMore() { function handleLoadMore() {
if (loadingMore.value || !hasMore.value) if (loadingMore.value || !hasMore.value)
return return
loadingMore.value = true pageNum.value += 1
fetchFavoriteList(false)
setTimeout(() => {
pageSize.value += CASE_PAGE_SIZE
loadingMore.value = false
}, LOAD_MORE_DELAY)
} }
function handleOpenFilter() { function handleOpenFilter() {
@@ -100,10 +143,15 @@ function handleCaseTap(id: string) {
} }
function showIndustryPicker() { function showIndustryPicker() {
if (!industries.value.length) {
uni.showToast({ title: '暂无行业选项', icon: 'none' })
return
}
uni.showActionSheet({ uni.showActionSheet({
itemList: [...CASE_FILTER_INDUSTRIES], itemList: [...industries.value],
success: ({ tapIndex }) => { success: ({ tapIndex }) => {
const industry = CASE_FILTER_INDUSTRIES[tapIndex] const industry = industries.value[tapIndex]
const next = cloneCaseFilter(appliedFilter.value) const next = cloneCaseFilter(appliedFilter.value)
next.industries = next.industries.includes(industry) next.industries = next.industries.includes(industry)
? next.industries.filter(item => item !== industry) ? next.industries.filter(item => item !== industry)
@@ -250,7 +298,7 @@ function showTimePicker() {
<view class="case-favorites__toolbar"> <view class="case-favorites__toolbar">
<text class="case-favorites__count"> <text class="case-favorites__count">
共 {{ filteredCases.length }} 共 {{ totalCount }}
</text> </text>
</view> </view>
</view> </view>
@@ -264,7 +312,7 @@ function showTimePicker() {
> >
<view class="case-favorites__list"> <view class="case-favorites__list">
<CaseCard <CaseCard
v-for="item in visibleCases" v-for="item in filteredCases"
:key="item.id" :key="item.id"
:item="item" :item="item"
@toggle-favorite="handleToggleFavorite" @toggle-favorite="handleToggleFavorite"
@@ -272,7 +320,7 @@ function showTimePicker() {
/> />
<LoadMoreFooter <LoadMoreFooter
:loading="loadingMore" :loading="loadingList || loadingMore"
:has-more="hasMore" :has-more="hasMore"
:finished="loadFinished" :finished="loadFinished"
@load-more="handleLoadMore" @load-more="handleLoadMore"
@@ -283,6 +331,7 @@ function showTimePicker() {
<CaseFilterPanel <CaseFilterPanel
:visible="filterVisible" :visible="filterVisible"
:cases="caseList" :cases="caseList"
:industries="industries"
:value="appliedFilter" :value="appliedFilter"
@close="handleCloseFilter" @close="handleCloseFilter"
@confirm="handleConfirmFilter" @confirm="handleConfirmFilter"

View File

@@ -1,69 +1,133 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { getCaseLibraryList } from '@/api/case-library'
import AppTabBar from '@/components/AppTabBar.vue' import AppTabBar from '@/components/AppTabBar.vue'
import { useCaseFavorites } from '@/hooks/use-case-favorites' import { useCaseFavorites } from '@/hooks/use-case-favorites'
import { useCaseIndustries } from '@/hooks/use-case-industries'
import { import {
CASE_CATEGORIES, CASE_CATEGORIES,
CASE_LIBRARY_LIST,
CASE_PAGE_SIZE, CASE_PAGE_SIZE,
DEFAULT_CASE_FILTER, DEFAULT_CASE_FILTER,
} from '@/constants/case-library' } from '@/constants/case-library'
import type { CaseCategory, CaseFilterValues } from '@/types/case-library' import type { CaseCategory, CaseFilterValues, CaseItem } from '@/types/case-library'
import { ensureLoggedIn } from '@/utils/auth-guard'
import { buildCaseLibraryQuery } from '@/utils/build-case-library-query'
import { cloneCaseFilter } from '@/utils/case-filter'
import { mapProjectCasesToCaseItems } from '@/utils/map-case-library'
import filterIcon from '@/static/icons/icon-filter.svg' import filterIcon from '@/static/icons/icon-filter.svg'
import { cloneCaseFilter, matchCaseFilter } from '@/utils/case-filter'
import CaseCard from './components/CaseCard.vue' import CaseCard from './components/CaseCard.vue'
import CaseFilterPanel from './components/CaseFilterPanel.vue' import CaseFilterPanel from './components/CaseFilterPanel.vue'
import LoadMoreFooter from './components/LoadMoreFooter.vue' import LoadMoreFooter from './components/LoadMoreFooter.vue'
const LOAD_MORE_DELAY = 600 const { toggleFavorite } = useCaseFavorites()
const { industries, loadCaseIndustries } = useCaseIndustries()
const { applyFavoritesToList, syncFavoriteIds, toggleFavorite } = useCaseFavorites()
const keyword = ref('') const keyword = ref('')
const activeCategory = ref<CaseCategory>('全部') const activeCategory = ref<CaseCategory>('全部')
const caseList = ref(applyFavoritesToList(CASE_LIBRARY_LIST.map(item => ({ ...item })))) const caseList = ref<CaseItem[]>([])
const pageSize = ref(CASE_PAGE_SIZE) const totalCount = ref(0)
const pageNum = ref(1)
const loadingList = ref(false)
const loadingMore = ref(false) const loadingMore = ref(false)
const filterVisible = ref(false) const filterVisible = ref(false)
const appliedFilter = ref<CaseFilterValues>(cloneCaseFilter(DEFAULT_CASE_FILTER)) const appliedFilter = ref<CaseFilterValues>(cloneCaseFilter(DEFAULT_CASE_FILTER))
const filteredCases = computed(() => { const hasMore = computed(() => caseList.value.length < totalCount.value)
const text = keyword.value.trim() const loadFinished = computed(() => !hasMore.value && caseList.value.length > 0)
return caseList.value.filter((item) => { let keywordTimer: ReturnType<typeof setTimeout> | null = null
const matchCategory = activeCategory.value === '全部' || item.category === activeCategory.value
const matchKeyword = !text || item.title.includes(text) || item.industry.includes(text) || item.category.includes(text) async function fetchCaseList(reset = false) {
const matchFilter = matchCaseFilter(item, appliedFilter.value) if (reset) {
return matchCategory && matchKeyword && matchFilter pageNum.value = 1
}) caseList.value = []
}
if (loadingList.value || loadingMore.value)
return
if (reset)
loadingList.value = true
else
loadingMore.value = true
try {
const params = buildCaseLibraryQuery({
keyword: keyword.value,
activeCategory: activeCategory.value,
filter: appliedFilter.value,
pageNum: pageNum.value,
pageSize: CASE_PAGE_SIZE,
}) })
const visibleCases = computed(() => filteredCases.value.slice(0, pageSize.value)) const result = await getCaseLibraryList(params)
const rows = result?.rows ?? []
const mapped = await mapProjectCasesToCaseItems(rows)
const hasMore = computed(() => pageSize.value < filteredCases.value.length) totalCount.value = Number(result?.total ?? 0)
caseList.value = reset
? mapped
: [...caseList.value, ...mapped]
}
catch {
if (reset) {
caseList.value = []
totalCount.value = 0
}
}
finally {
loadingList.value = false
loadingMore.value = false
}
}
const loadFinished = computed(() => !hasMore.value && visibleCases.value.length > 0) watch(keyword, () => {
if (keywordTimer)
clearTimeout(keywordTimer)
keywordTimer = setTimeout(() => {
fetchCaseList(true)
}, 400)
})
watch([keyword, activeCategory, appliedFilter], () => { watch(activeCategory, () => {
pageSize.value = CASE_PAGE_SIZE fetchCaseList(true)
}) })
onShow(() => { onShow(() => {
syncFavoriteIds() loadCaseIndustries()
caseList.value = applyFavoritesToList(CASE_LIBRARY_LIST.map(item => ({ ...item }))) fetchCaseList(true)
}) })
function handleSelectCategory(category: CaseCategory) { function handleSelectCategory(category: CaseCategory) {
activeCategory.value = category activeCategory.value = category
} }
function handleToggleFavorite(id: string) { async function handleToggleFavorite(id: string) {
toggleFavorite(id) if (!ensureLoggedIn())
caseList.value = applyFavoritesToList(CASE_LIBRARY_LIST.map(item => ({ ...item }))) return
const target = caseList.value.find(item => item.id === id)
if (!target)
return
const nextFavorite = !target.favorite
caseList.value = caseList.value.map(item => (
item.id === id ? { ...item, favorite: nextFavorite } : item
))
const success = await toggleFavorite(id, target.favorite ?? false)
if (!success) {
caseList.value = caseList.value.map(item => (
item.id === id ? { ...item, favorite: target.favorite } : item
))
}
} }
function handleOpenFavorites() { function handleOpenFavorites() {
if (!ensureLoggedIn())
return
uni.navigateTo({ uni.navigateTo({
url: '/pages/case-library/favorites/index', url: '/pages/case-library/favorites/index',
}) })
@@ -73,12 +137,8 @@ function handleLoadMore() {
if (loadingMore.value || !hasMore.value) if (loadingMore.value || !hasMore.value)
return return
loadingMore.value = true pageNum.value += 1
fetchCaseList(false)
setTimeout(() => {
pageSize.value += CASE_PAGE_SIZE
loadingMore.value = false
}, LOAD_MORE_DELAY)
} }
function handleOpenFilter() { function handleOpenFilter() {
@@ -91,6 +151,7 @@ function handleCloseFilter() {
function handleConfirmFilter(value: CaseFilterValues) { function handleConfirmFilter(value: CaseFilterValues) {
appliedFilter.value = cloneCaseFilter(value) appliedFilter.value = cloneCaseFilter(value)
fetchCaseList(true)
} }
function handleCaseTap(id: string) { function handleCaseTap(id: string) {
@@ -153,7 +214,7 @@ function handleCaseTap(id: string) {
<view class="case-library__toolbar"> <view class="case-library__toolbar">
<text class="case-library__count"> <text class="case-library__count">
共 {{ filteredCases.length }} 共 {{ totalCount }}
</text> </text>
<view <view
class="case-library__filter" class="case-library__filter"
@@ -180,7 +241,7 @@ function handleCaseTap(id: string) {
> >
<view class="case-library__list"> <view class="case-library__list">
<CaseCard <CaseCard
v-for="item in visibleCases" v-for="item in caseList"
:key="item.id" :key="item.id"
:item="item" :item="item"
@toggle-favorite="handleToggleFavorite" @toggle-favorite="handleToggleFavorite"
@@ -188,7 +249,7 @@ function handleCaseTap(id: string) {
/> />
<LoadMoreFooter <LoadMoreFooter
:loading="loadingMore" :loading="loadingList || loadingMore"
:has-more="hasMore" :has-more="hasMore"
:finished="loadFinished" :finished="loadFinished"
@load-more="handleLoadMore" @load-more="handleLoadMore"
@@ -201,6 +262,7 @@ function handleCaseTap(id: string) {
<CaseFilterPanel <CaseFilterPanel
:visible="filterVisible" :visible="filterVisible"
:cases="caseList" :cases="caseList"
:industries="industries"
:value="appliedFilter" :value="appliedFilter"
@close="handleCloseFilter" @close="handleCloseFilter"
@confirm="handleConfirmFilter" @confirm="handleConfirmFilter"

View File

@@ -0,0 +1,23 @@
.home-banner {
width: 100%;
margin-bottom: 20rpx;
overflow: hidden;
border-radius: 20rpx;
}
.home-banner__swiper {
width: 100%;
height: 280rpx;
}
.home-banner__item {
width: 100%;
height: 100%;
}
.home-banner__image {
display: block;
width: 100%;
height: 100%;
border-radius: 20rpx;
}

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { BannerItem } from '@/types/banner'
defineProps<{
list: BannerItem[]
}>()
</script>
<template>
<view
v-if="list.length"
class="home-banner"
>
<swiper
class="home-banner__swiper"
circular
autoplay
:interval="4000"
indicator-dots
indicator-color="rgba(255, 255, 255, 0.45)"
indicator-active-color="#ffffff"
>
<swiper-item
v-for="item in list"
:key="item.id"
class="home-banner__item"
>
<image
class="home-banner__image"
:src="item.imageUrl"
mode="aspectFill"
/>
</swiper-item>
</swiper>
</view>
</template>
<style scoped lang="scss">
@import './HomeBanner.scss';
</style>

View File

@@ -6,6 +6,13 @@ const props = defineProps<{
}>() }>()
function handleTap() { function handleTap() {
if (props.item.submissionId) {
uni.navigateTo({
url: `/pages/home-index/project-submission-detail/index?id=${props.item.submissionId}`,
})
return
}
if (!props.item.detailId) if (!props.item.detailId)
return return
@@ -21,7 +28,7 @@ function handleTap() {
:class="[ :class="[
`service-card--${item.theme}`, `service-card--${item.theme}`,
{ 'service-card--with-illus': item.illus }, { 'service-card--with-illus': item.illus },
{ 'service-card--clickable': item.detailId }, { 'service-card--clickable': item.detailId || item.submissionId },
]" ]"
@tap="handleTap" @tap="handleTap"
> >

View File

@@ -128,11 +128,14 @@
.home__section { .home__section {
margin-bottom: 20rpx; margin-bottom: 20rpx;
height: 600rpx;
width: 711.83rpx; width: 711.83rpx;
min-height: 320rpx;
height: auto;
background: $color-bg-page; background: $color-bg-page;
border-radius: 20rpx; border-radius: 20rpx;
padding-top: 20rpx padding-top: 20rpx;
padding-bottom: 24rpx;
box-sizing: border-box;
} }
.home__sections { .home__sections {
@@ -186,6 +189,14 @@
overflow: visible; overflow: visible;
} }
.home__service-empty {
display: block;
padding: 48rpx 20rpx 24rpx;
font-size: 26rpx;
color: $color-text-secondary;
text-align: center;
}
.home__quick-row { .home__quick-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@@ -1,16 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { getBannerList } from '@/api/banner'
import { getProjectSubmissionList } from '@/api/project-submission'
import AppTabBar from '@/components/AppTabBar.vue' import AppTabBar from '@/components/AppTabBar.vue'
import { useGreeting } from '@/hooks/use-greeting' import { useGreeting } from '@/hooks/use-greeting'
import { useQuickEntries } from '@/hooks/use-quick-entries' import { useQuickEntries } from '@/hooks/use-quick-entries'
import type { BannerItem } from '@/types/banner'
import type { ProjectItem, QuickEntryItem, ServiceItem } from '@/types/home' import type { ProjectItem, QuickEntryItem, ServiceItem } from '@/types/home'
import { ensureLoggedIn } from '@/utils/auth-guard' import { ensureLoggedIn } from '@/utils/auth-guard'
import { mapProjectSubmissionsToServices } from '@/utils/map-project-submission'
import { navigateQuickEntry } from '@/utils/quick-entry-navigate' import { navigateQuickEntry } from '@/utils/quick-entry-navigate'
import headerBg from '@/static/icons/1.png' import headerBg from '@/static/icons/1.png'
import softwareIllus from '@/static/icons/software.png' import HomeBanner from './components/HomeBanner.vue'
import hardwareIllus from '@/static/icons/hardware.png'
import iotIllus from '@/static/icons/iot.png'
import productionIllus from '@/static/icons/production.png'
import HomeFloatingActions from './components/HomeFloatingActions.vue' import HomeFloatingActions from './components/HomeFloatingActions.vue'
import ProjectCard from './components/ProjectCard.vue' import ProjectCard from './components/ProjectCard.vue'
import QuickEntryEditor from './components/QuickEntryEditor.vue' import QuickEntryEditor from './components/QuickEntryEditor.vue'
@@ -27,41 +29,37 @@ const {
addToPinned, addToPinned,
} = useQuickEntries() } = useQuickEntries()
onShow(() => { const banners = ref<BannerItem[]>([])
ensureLoggedIn() const services = ref<ServiceItem[]>([])
})
const services: ServiceItem[] = [ async function loadBanners() {
{ try {
id: '1', const list = await getBannerList()
title: '互联网软件', banners.value = (list ?? [])
desc: 'Web/小程序/APP/二次开发/桌面工具等', .filter(item => item.enabled === 1 || item.enabled === undefined)
theme: 'blue', .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
illus: softwareIllus, }
detailId: 'software', catch {
}, banners.value = []
{ }
id: '2', }
title: '硬件电路',
desc: '原理图设计/PCB设计/电气校验等', async function loadProjectSubmissions() {
theme: 'green', try {
illus: hardwareIllus, const list = await getProjectSubmissionList()
}, services.value = await mapProjectSubmissionsToServices(list ?? [])
{ }
id: '3', catch {
title: '物联网全案', services.value = []
desc: '软硬件开发+云端一体化/定制化开发等', }
theme: 'pink', }
illus: iotIllus,
}, onShow(() => {
{ if (!ensureLoggedIn())
id: '4', return
title: '生产制造', loadBanners()
desc: 'PCB制版/SMT/整机装配/定制生产等', loadProjectSubmissions()
theme: 'orange', })
illus: productionIllus,
},
]
function handleQuickEntryTap(entry: QuickEntryItem) { function handleQuickEntryTap(entry: QuickEntryItem) {
navigateQuickEntry(entry.id, entry.label) navigateQuickEntry(entry.id, entry.label)
@@ -148,17 +146,28 @@ const projects: ProjectItem[] = [
:show-scrollbar="false" :show-scrollbar="false"
> >
<view class="home__content"> <view class="home__content">
<HomeBanner :list="banners" />
<view class="home__section"> <view class="home__section">
<text class="home__section-title"> <text class="home__section-title">
业务服务 业务服务
</text> </text>
<view class="home__service-grid"> <view
v-if="services.length"
class="home__service-grid"
>
<ServiceCard <ServiceCard
v-for="item in services" v-for="item in services"
:key="item.id" :key="item.id"
:item="item" :item="item"
/> />
</view> </view>
<text
v-else
class="home__service-empty"
>
暂无业务服务
</text>
</view> </view>
<view class="home__sections"> <view class="home__sections">

View File

@@ -0,0 +1,188 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getProjectSubmissionDetail } from '@/api/project-submission'
import type { ProjectSubmissionItem, ProjectServiceType } from '@/types/project-submission'
import { ensureLoggedIn } from '@/utils/auth-guard'
import { navigateBackWithFallback } from '@/utils/navigate-back'
import { resolveProjectSubmissionMedia } from '@/utils/resolve-project-submission-media'
import heroBg from '@/static/icons/background.png'
const detail = ref<ProjectSubmissionItem | null>(null)
const loading = ref(true)
const heroLogo = ref('')
const serviceIcons = ref<Record<string, string>>({})
const sortedServiceTypes = computed<ProjectServiceType[]>(() => {
if (!detail.value?.serviceTypes?.length)
return []
return [...detail.value.serviceTypes].sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
)
})
async function loadDetail(id: string) {
loading.value = true
uni.showLoading({ title: '加载中...', mask: true })
try {
const data = await getProjectSubmissionDetail(id)
detail.value = data
const media = await resolveProjectSubmissionMedia(data)
heroLogo.value = media.logoUrl || ''
serviceIcons.value = media.serviceIcons
}
catch {
detail.value = null
uni.showToast({ title: '加载失败', icon: 'none' })
}
finally {
loading.value = false
uni.hideLoading()
}
}
onLoad((options) => {
const id = options?.id as string
if (!id) {
loading.value = false
uni.showToast({ title: '参数错误', icon: 'none' })
return
}
loadDetail(id)
})
onShow(() => {
ensureLoggedIn()
})
function handleBack() {
navigateBackWithFallback('/pages/home-index/index')
}
function handleSubmit() {
if (!detail.value)
return
const businessType = detail.value.businessType || detail.value.title
uni.navigateTo({
url: `/pages/home-index/requirement-submit/index?submissionId=${detail.value.id}&businessType=${encodeURIComponent(businessType)}`,
})
}
</script>
<template>
<view
v-if="detail"
class="business-detail"
>
<view class="business-detail__navbar">
<view
class="business-detail__back"
@tap="handleBack"
>
<image
class="business-detail__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="business-detail__navbar-title">
业务详情
</text>
<view class="business-detail__navbar-placeholder" />
</view>
<scroll-view
class="business-detail__scroll"
scroll-y
:show-scrollbar="false"
>
<view class="business-detail__hero">
<image
class="business-detail__hero-bg"
:src="heroBg"
mode="scaleToFill"
/>
<view class="business-detail__hero-inner">
<image
v-if="heroLogo"
class="business-detail__hero-icon"
:src="heroLogo"
mode="aspectFit"
/>
<text class="business-detail__hero-title">
{{ detail.title }}
</text>
<text
v-if="detail.subtitle"
class="business-detail__hero-subtitle"
>
{{ detail.subtitle }}
</text>
</view>
</view>
<view
v-if="sortedServiceTypes.length"
class="business-detail__section"
>
<text class="business-detail__section-title">
服务类型
</text>
<view class="business-detail__type-row">
<view
v-for="type in sortedServiceTypes"
:key="type.id"
class="business-detail__type-item"
>
<image
v-if="serviceIcons[String(type.id)]"
class="business-detail__type-icon"
:src="serviceIcons[String(type.id)]"
mode="aspectFit"
/>
<text class="business-detail__type-label">
{{ type.serviceName }}
</text>
</view>
</view>
</view>
</scroll-view>
<view class="business-detail__footer">
<view
class="business-detail__submit-btn"
@tap="handleSubmit"
>
立即提报
</view>
</view>
</view>
<view
v-else-if="!loading"
class="business-detail business-detail--empty"
>
<text class="business-detail__empty-text">
暂无详情
</text>
</view>
</template>
<style scoped lang="scss">
@import '../business-detail/index.scss';
.business-detail--empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.business-detail__empty-text {
font-size: 28rpx;
color: #999999;
}
</style>

7
src/types/banner.ts Normal file
View File

@@ -0,0 +1,7 @@
export interface BannerItem {
id: number
imageUrl: string
sort?: number
enabled?: number
updateTime?: string | null
}

View File

@@ -0,0 +1,68 @@
export interface CaseLibraryPageQuery {
pageNum?: number
pageSize?: number
orderByColumn?: string
isAsc?: string
}
export interface CaseLibraryListQuery extends CaseLibraryPageQuery {
keyword?: string
businessType?: string
industry?: string
scaleMin?: number
scaleMax?: number
completedAtBegin?: string
completedAtEnd?: string
timeRange?: string
}
export interface CaseLibraryFavoriteListQuery extends CaseLibraryPageQuery {
pageNum: number
pageSize: number
}
export interface ProjectCaseAttachmentVo {
id?: number | string
projectId?: number | string
ossId?: string
originalName?: string
fileSize?: string
url?: string
sortOrder?: number
}
export interface ProjectCaseCatalogVo {
id?: number | string
projectId?: number | string
title?: string
content?: string
sortOrder?: number
}
export interface ProjectCaseVo {
id: number | string
caseNo?: string
caseName?: string
businessType?: string
businessIndustry?: string
industry?: string | null
enabled?: number
scale?: number | string
previewCount?: number
viewCount?: number
favoriteCount?: number
favorited?: boolean | null
coverUrl?: string | null
customerType?: string | null
projectCycle?: string | number | null
serviceTeam?: number | null
completedAt?: string | null
attachments?: ProjectCaseAttachmentVo[]
catalogs?: ProjectCaseCatalogVo[]
updateTime?: string | null
}
export interface CaseLibraryPageResult {
total: number
rows: ProjectCaseVo[]
}

View File

@@ -18,13 +18,17 @@ export interface CaseAttachment {
id: string id: string
name: string name: string
icon: string icon: string
url?: string
}
export interface CaseCatalogSection {
id: string
title: string
content: string
} }
export interface CaseDetail extends CaseItem { export interface CaseDetail extends CaseItem {
background: string catalogs: CaseCatalogSection[]
painPoints: string
requirement: string
deliverables: string
attachments: CaseAttachment[] attachments: CaseAttachment[]
} }

View File

@@ -5,6 +5,8 @@ export interface ServiceItem {
theme: 'blue' | 'green' | 'pink' | 'orange' theme: 'blue' | 'green' | 'pink' | 'orange'
illus?: string illus?: string
detailId?: string detailId?: string
/** App 项目提报 id,跳转提报详情 */
submissionId?: string
} }
export interface QuickEntryItem { export interface QuickEntryItem {

View File

@@ -0,0 +1,19 @@
export interface ProjectServiceType {
id?: number | string
projectId?: number | string
serviceName?: string
iconUrl?: string
sortOrder?: number
}
export interface ProjectSubmissionItem {
id: number | string
businessType?: string
title: string
subtitle?: string
enabled?: number
coverUrl?: string
logoUrl?: string
serviceTypes?: ProjectServiceType[]
updateTime?: string | null
}

View File

@@ -1,7 +1,9 @@
import { getAccessToken } from '@/utils/auth-storage' import { clearAuthSession, getAccessToken } from '@/utils/auth-storage'
export const LOGIN_PAGE = '/pages/Login/user-login/index' export const LOGIN_PAGE = '/pages/Login/user-login/index'
let redirectingToLogin = false
export function isLoggedIn() { export function isLoggedIn() {
return Boolean(getAccessToken()) return Boolean(getAccessToken())
} }
@@ -14,6 +16,26 @@ export function ensureLoggedIn(showToast = true) {
if (showToast) if (showToast)
uni.showToast({ title: '请先登录', icon: 'none' }) uni.showToast({ title: '请先登录', icon: 'none' })
uni.reLaunch({ url: LOGIN_PAGE }) redirectToLogin()
return false return false
} }
/** 登录态失效(如接口返回 401)时清除会话并跳转登录页 */
export function handleUnauthorized(message = '登录已过期,请重新登录') {
if (redirectingToLogin)
return
redirectingToLogin = true
clearAuthSession()
uni.showToast({ title: message, icon: 'none' })
redirectToLogin()
}
function redirectToLogin() {
uni.reLaunch({
url: LOGIN_PAGE,
complete: () => {
redirectingToLogin = false
},
})
}

View File

@@ -1,18 +1,40 @@
import { AUTH_CLIENT_ID } from '@/constants/config'
const ACCESS_TOKEN_KEY = 'access_token' const ACCESS_TOKEN_KEY = 'access_token'
const AUTH_CLIENT_ID_KEY = 'auth_client_id'
const LOGIN_PHONE_KEY = 'login_phone_number' const LOGIN_PHONE_KEY = 'login_phone_number'
export function setAccessToken(token: string) { export function setAccessToken(token: string) {
uni.setStorageSync(ACCESS_TOKEN_KEY, token) uni.setStorageSync(ACCESS_TOKEN_KEY, String(token).trim())
} }
export function getAccessToken() { export function getAccessToken() {
return uni.getStorageSync(ACCESS_TOKEN_KEY) as string const token = uni.getStorageSync(ACCESS_TOKEN_KEY) as string
return token ? String(token).trim() : ''
} }
export function clearAccessToken() { export function clearAccessToken() {
uni.removeStorageSync(ACCESS_TOKEN_KEY) uni.removeStorageSync(ACCESS_TOKEN_KEY)
} }
export function setAuthClientId(clientId: string) {
uni.setStorageSync(AUTH_CLIENT_ID_KEY, String(clientId).trim())
}
export function getAuthClientId() {
const stored = uni.getStorageSync(AUTH_CLIENT_ID_KEY) as string
return stored ? String(stored).trim() : AUTH_CLIENT_ID
}
export function clearAuthClientId() {
uni.removeStorageSync(AUTH_CLIENT_ID_KEY)
}
export function saveAuthSession(token: string, clientId?: string) {
setAccessToken(token)
setAuthClientId(clientId || AUTH_CLIENT_ID)
}
export function setLoginPhoneNumber(phoneNumber: string) { export function setLoginPhoneNumber(phoneNumber: string) {
uni.setStorageSync(LOGIN_PHONE_KEY, phoneNumber) uni.setStorageSync(LOGIN_PHONE_KEY, phoneNumber)
} }
@@ -27,5 +49,6 @@ export function clearLoginPhoneNumber() {
export function clearAuthSession() { export function clearAuthSession() {
clearAccessToken() clearAccessToken()
clearAuthClientId()
clearLoginPhoneNumber() clearLoginPhoneNumber()
} }

View File

@@ -0,0 +1,94 @@
import { CASE_PAGE_SIZE } from '@/constants/case-library'
import type { CaseCategory, CaseFilterValues } from '@/types/case-library'
import type { CaseLibraryListQuery } from '@/types/case-library-api'
const TIME_RANGE_API_MAP: Record<string, string> = {
近3个月: '3m',
近半年: '6m',
近一年: '1y',
}
function scaleRangeToBounds(range: string): { min?: number, max?: number } {
switch (range) {
case '50万以下':
return { max: 50 }
case '50-100万':
return { min: 50, max: 100 }
case '100-300万':
return { min: 100, max: 300 }
case '300-500万':
return { min: 300, max: 500 }
case '500万以上':
return { min: 500 }
default:
return {}
}
}
function mergeScaleBounds(scales: string[]) {
let scaleMin: number | undefined
let scaleMax: number | undefined
for (const range of scales) {
const bounds = scaleRangeToBounds(range)
if (bounds.min !== undefined)
scaleMin = scaleMin === undefined ? bounds.min : Math.min(scaleMin, bounds.min)
if (bounds.max !== undefined)
scaleMax = scaleMax === undefined ? bounds.max : Math.max(scaleMax, bounds.max)
}
return { scaleMin, scaleMax }
}
export interface BuildCaseLibraryQueryInput {
keyword: string
activeCategory: CaseCategory
filter: CaseFilterValues
pageNum: number
pageSize?: number
}
export function buildCaseLibraryQuery(input: BuildCaseLibraryQueryInput): CaseLibraryListQuery {
const {
keyword,
activeCategory,
filter,
pageNum,
pageSize = CASE_PAGE_SIZE,
} = input
const query: CaseLibraryListQuery = {
pageNum,
pageSize,
}
const text = keyword.trim()
if (text)
query.keyword = text
if (activeCategory !== '全部')
query.businessType = activeCategory
if (filter.industries.length === 1)
query.industry = filter.industries[0]
else if (filter.industries.length > 1)
query.industry = filter.industries.join(',')
const { scaleMin, scaleMax } = mergeScaleBounds(filter.scales)
if (scaleMin !== undefined)
query.scaleMin = scaleMin
if (scaleMax !== undefined)
query.scaleMax = scaleMax
if (filter.dateRange.start && filter.dateRange.end) {
query.completedAtBegin = filter.dateRange.start.replace(/\//g, '-')
query.completedAtEnd = filter.dateRange.end.replace(/\//g, '-')
}
else if (filter.timeRanges.length === 1) {
const mapped = TIME_RANGE_API_MAP[filter.timeRanges[0]]
if (mapped)
query.timeRange = mapped
}
return query
}

View File

@@ -0,0 +1,104 @@
import { CASE_CATEGORIES } from '@/constants/case-library'
import type { CaseCategory, CaseDetail, CaseItem } from '@/types/case-library'
import type { ProjectCaseVo } from '@/types/case-library-api'
import { pickMediaUrl, resolveOssUrlMap } from '@/utils/resolve-oss-media'
const FALLBACK_COVER = '/static/icons/background.png'
function mapBusinessType(value?: string): Exclude<CaseCategory, '全部'> {
const categories = CASE_CATEGORIES.filter(item => item !== '全部') as Exclude<CaseCategory, '全部'>[]
if (value && categories.includes(value as Exclude<CaseCategory, '全部'>))
return value as Exclude<CaseCategory, '全部'>
return '互联网软件'
}
function formatScale(scale?: number | string | null) {
if (scale === null || scale === undefined || scale === '')
return '—'
const num = Number(scale)
if (Number.isNaN(num))
return String(scale)
return `${num}万`
}
function formatCycle(cycle?: string | number | null) {
if (cycle === null || cycle === undefined || cycle === '')
return '—'
return `${cycle}个工作日`
}
function formatCompletedAt(value?: string | null) {
if (!value)
return '—'
return value.replace(/-/g, '/').slice(0, 10)
}
function formatCustomerType(value?: string | null) {
if (!value)
return '—'
return value
}
function attachmentIcon(name: string) {
const lower = name.toLowerCase()
if (lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.jpeg') || lower.endsWith('.webp'))
return '/static/icons/PNG.png'
return '/static/icons/PDF.png'
}
export async function mapProjectCaseToCaseDetail(vo: ProjectCaseVo): Promise<CaseDetail> {
const [baseItem] = await mapProjectCasesToCaseItems([vo])
const attachmentSources = (vo.attachments ?? []).flatMap(item => [item.url, item.ossId])
const urlMap = await resolveOssUrlMap(attachmentSources)
const catalogs = [...(vo.catalogs ?? [])]
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map(item => ({
id: String(item.id ?? item.title ?? ''),
title: item.title || '—',
content: item.content || '',
}))
const attachments = [...(vo.attachments ?? [])]
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((item) => {
const name = item.originalName || '附件'
const rawUrl = item.url || item.ossId
return {
id: String(item.id ?? name),
name,
icon: attachmentIcon(name),
url: pickMediaUrl(rawUrl, urlMap) || rawUrl || undefined,
}
})
return {
...baseItem,
catalogs,
attachments,
}
}
export async function mapProjectCasesToCaseItems(rows: ProjectCaseVo[]): Promise<CaseItem[]> {
const coverIds = rows.map(row => row.coverUrl).filter(Boolean) as string[]
const urlMap = await resolveOssUrlMap(coverIds)
return rows.map((row) => {
const industry = row.industry || row.businessIndustry || '—'
const cover = pickMediaUrl(row.coverUrl || undefined, urlMap) || FALLBACK_COVER
return {
id: String(row.id),
title: row.caseName || '未命名案例',
category: mapBusinessType(row.businessType),
cover,
clientType: formatCustomerType(row.customerType),
industry,
scale: formatScale(row.scale),
cycle: formatCycle(row.projectCycle),
teamSize: row.serviceTeam != null ? `${row.serviceTeam}人` : '—',
completedAt: formatCompletedAt(row.completedAt),
favorite: Boolean(row.favorited),
}
})
}

View File

@@ -0,0 +1,32 @@
import type { ProjectSubmissionItem } from '@/types/project-submission'
import type { ServiceItem } from '@/types/home'
import { pickMediaUrl, resolveOssUrlMap } from '@/utils/resolve-oss-media'
const SERVICE_THEMES: ServiceItem['theme'][] = ['blue', 'green', 'pink', 'orange']
function collectMediaIds(items: ProjectSubmissionItem[]) {
const ids: string[] = []
for (const item of items) {
if (item.logoUrl)
ids.push(String(item.logoUrl))
if (item.coverUrl)
ids.push(String(item.coverUrl))
}
return ids
}
export async function mapProjectSubmissionsToServices(
list: ProjectSubmissionItem[],
): Promise<ServiceItem[]> {
const enabled = list.filter(item => item.enabled === 1 || item.enabled === undefined)
const urlMap = await resolveOssUrlMap(collectMediaIds(enabled))
return enabled.map((item, index) => ({
id: String(item.id),
title: item.title,
desc: item.subtitle || item.businessType || '',
theme: SERVICE_THEMES[index % SERVICE_THEMES.length],
illus: pickMediaUrl(item.logoUrl, urlMap) || pickMediaUrl(item.coverUrl, urlMap),
submissionId: String(item.id),
}))
}

View File

@@ -1,5 +1,6 @@
import { API_BASE_URL, API_TIMEOUT } from '@/constants/config' import { API_BASE_URL, API_TIMEOUT } from '@/constants/config'
import { getAccessToken } from '@/utils/auth-storage' import { handleUnauthorized } from '@/utils/auth-guard'
import { getAccessToken, getAuthClientId } from '@/utils/auth-storage'
export interface RequestOptions { export interface RequestOptions {
url: string url: string
@@ -41,12 +42,14 @@ export function request<T = unknown>(options: RequestOptions): Promise<T> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const token = getAccessToken() const token = getAccessToken()
const clientId = getAuthClientId()
uni.request({ uni.request({
url: resolveUrl(url), url: resolveUrl(url),
method, method,
data, data,
header: { header: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(clientId ? { clientid: clientId } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
...header, ...header,
}, },
@@ -64,6 +67,12 @@ export function request<T = unknown>(options: RequestOptions): Promise<T> {
} }
const message = getErrorMessage(apiBody, '请求失败') const message = getErrorMessage(apiBody, '请求失败')
if (apiBody.code === 401) {
handleUnauthorized(message)
reject(new Error(message))
return
}
if (showErrorToast) if (showErrorToast)
uni.showToast({ title: message, icon: 'none' }) uni.showToast({ title: message, icon: 'none' })
reject(new Error(message)) reject(new Error(message))
@@ -74,6 +83,12 @@ export function request<T = unknown>(options: RequestOptions): Promise<T> {
return return
} }
if (statusCode === 401) {
handleUnauthorized('登录状态异常,请重新登录')
reject(new Error('登录状态异常,请重新登录'))
return
}
const message = `请求失败(${statusCode})` const message = `请求失败(${statusCode})`
if (showErrorToast) if (showErrorToast)
uni.showToast({ title: message, icon: 'none' }) uni.showToast({ title: message, icon: 'none' })

View File

@@ -0,0 +1,36 @@
import { getOssListByIds } from '@/api/oss'
export function isHttpUrl(value?: string) {
return Boolean(value && /^https?:\/\//.test(value))
}
export async function resolveOssUrlMap(rawIds: Array<string | undefined>) {
const map = new Map<string, string>()
const ossIds = rawIds
.filter((id): id is string => Boolean(id && !isHttpUrl(id)))
.map(String)
if (!ossIds.length)
return map
try {
const files = await getOssListByIds(ossIds)
for (const file of files ?? []) {
if (file.ossId && file.url)
map.set(String(file.ossId), file.url)
}
}
catch {
// ignore
}
return map
}
export function pickMediaUrl(raw?: string, urlMap?: Map<string, string>) {
if (!raw)
return undefined
if (isHttpUrl(raw))
return raw
return urlMap?.get(String(raw))
}

View File

@@ -0,0 +1,22 @@
import type { ProjectSubmissionItem } from '@/types/project-submission'
import { pickMediaUrl, resolveOssUrlMap } from '@/utils/resolve-oss-media'
export async function resolveProjectSubmissionMedia(item: ProjectSubmissionItem) {
const serviceIconIds = (item.serviceTypes ?? []).map(st => st.iconUrl)
const urlMap = await resolveOssUrlMap([item.logoUrl, item.coverUrl, ...serviceIconIds])
const serviceIcons: Record<string, string> = {}
for (const st of item.serviceTypes ?? []) {
if (!st.id)
continue
const url = pickMediaUrl(st.iconUrl, urlMap)
if (url)
serviceIcons[String(st.id)] = url
}
return {
logoUrl: pickMediaUrl(item.logoUrl, urlMap) || pickMediaUrl(item.coverUrl, urlMap),
coverUrl: pickMediaUrl(item.coverUrl, urlMap),
serviceIcons,
}
}