From b0a711f70a20fa6991721ec60b6242c94f4c15be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BC=8A=E5=B8=86?= Date: Mon, 21 Sep 2026 15:41:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E9=A6=96=E9=A1=B5=E5=92=8C=E6=A1=88?= =?UTF-8?q?=E4=BE=8B=E5=BA=93=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/banner.ts | 10 + src/api/case-library.ts | 45 +++++ src/api/oss.ts | 14 ++ src/api/project-submission.ts | 16 ++ src/constants/case-detail.ts | 34 ---- src/constants/case-library.ts | 95 +-------- src/hooks/use-case-favorites.ts | 75 ++----- src/hooks/use-case-industries.ts | 34 ++++ src/pages.json | 6 + src/pages/Login/user-login/index.vue | 4 +- src/pages/Login/user-sms-login/index.vue | 4 +- .../components/CaseFilterPanel.vue | 4 +- src/pages/case-library/detail/index.scss | 10 +- src/pages/case-library/detail/index.vue | 129 ++++++++---- src/pages/case-library/favorites/index.vue | 109 +++++++--- src/pages/case-library/index.vue | 138 +++++++++---- .../home-index/components/HomeBanner.scss | 23 +++ .../home-index/components/HomeBanner.vue | 40 ++++ .../home-index/components/ServiceCard.vue | 9 +- src/pages/home-index/index.scss | 15 +- src/pages/home-index/index.vue | 87 ++++---- .../project-submission-detail/index.vue | 188 ++++++++++++++++++ src/types/banner.ts | 7 + src/types/case-library-api.ts | 68 +++++++ src/types/case-library.ts | 12 +- src/types/home.ts | 2 + src/types/project-submission.ts | 19 ++ src/utils/auth-guard.ts | 26 ++- src/utils/auth-storage.ts | 27 ++- src/utils/build-case-library-query.ts | 94 +++++++++ src/utils/map-case-library.ts | 104 ++++++++++ src/utils/map-project-submission.ts | 32 +++ src/utils/request.ts | 17 +- src/utils/resolve-oss-media.ts | 36 ++++ src/utils/resolve-project-submission-media.ts | 22 ++ 35 files changed, 1201 insertions(+), 354 deletions(-) create mode 100644 src/api/banner.ts create mode 100644 src/api/case-library.ts create mode 100644 src/api/oss.ts create mode 100644 src/api/project-submission.ts delete mode 100644 src/constants/case-detail.ts create mode 100644 src/hooks/use-case-industries.ts create mode 100644 src/pages/home-index/components/HomeBanner.scss create mode 100644 src/pages/home-index/components/HomeBanner.vue create mode 100644 src/pages/home-index/project-submission-detail/index.vue create mode 100644 src/types/banner.ts create mode 100644 src/types/case-library-api.ts create mode 100644 src/types/project-submission.ts create mode 100644 src/utils/build-case-library-query.ts create mode 100644 src/utils/map-case-library.ts create mode 100644 src/utils/map-project-submission.ts create mode 100644 src/utils/resolve-oss-media.ts create mode 100644 src/utils/resolve-project-submission-media.ts diff --git a/src/api/banner.ts b/src/api/banner.ts new file mode 100644 index 0000000..f0180b4 --- /dev/null +++ b/src/api/banner.ts @@ -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(BANNER_API.list) +} diff --git a/src/api/case-library.ts b/src/api/case-library.ts new file mode 100644 index 0000000..ffd101b --- /dev/null +++ b/src/api/case-library.ts @@ -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(CASE_LIBRARY_API.list, params) +} + +/** 查询案例详情:GET /app/caseLibrary/{id} */ +export function getCaseLibraryDetail(id: string | number) { + return get(CASE_LIBRARY_API.detail(id)) +} + +/** 案例库行业选项:GET /app/caseLibrary/industries */ +export function getCaseLibraryIndustries() { + return get(CASE_LIBRARY_API.industries, undefined, { showErrorToast: false }) +} + +/** 我的收藏列表:GET /app/caseLibrary/favorite/list */ +export function getCaseLibraryFavoriteList(params: CaseLibraryFavoriteListQuery) { + return get(CASE_LIBRARY_API.favoriteList, params) +} + +/** 收藏案例:POST /app/caseLibrary/{id}/favorite */ +export function favoriteCase(id: string | number) { + return post(CASE_LIBRARY_API.favorite(id), {}) +} + +/** 取消收藏:POST /app/caseLibrary/{id}/unfavorite */ +export function unfavoriteCase(id: string | number) { + return post(CASE_LIBRARY_API.unfavorite(id), {}) +} diff --git a/src/api/oss.ts b/src/api/oss.ts new file mode 100644 index 0000000..38204ae --- /dev/null +++ b/src/api/oss.ts @@ -0,0 +1,14 @@ +export interface OssFileItem { + ossId?: number | string + url?: string +} + +export function getOssListByIds(ossIds: Array) { + const ids = [...new Set(ossIds.map(id => String(id).trim()).filter(Boolean))] + if (!ids.length) + return Promise.resolve([] as OssFileItem[]) + + return get(`/resource/oss/listByIds/${ids.join(',')}`, undefined, { + showErrorToast: false, + }) +} diff --git a/src/api/project-submission.ts b/src/api/project-submission.ts new file mode 100644 index 0000000..f45f395 --- /dev/null +++ b/src/api/project-submission.ts @@ -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(PROJECT_SUBMISSION_API.list) +} + +/** 查询 App 项目提报详情:GET /app/projectSubmission/{id} */ +export function getProjectSubmissionDetail(id: string | number) { + return get(PROJECT_SUBMISSION_API.detail(id)) +} diff --git a/src/constants/case-detail.ts b/src/constants/case-detail.ts deleted file mode 100644 index 33ef350..0000000 --- a/src/constants/case-detail.ts +++ /dev/null @@ -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 = 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 -} diff --git a/src/constants/case-library.ts b/src/constants/case-library.ts index ee3903d..da016a9 100644 --- a/src/constants/case-library.ts +++ b/src/constants/case-library.ts @@ -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[] = [ '全部', @@ -10,17 +10,11 @@ export const CASE_CATEGORIES: CaseCategory[] = [ export const CASE_PAGE_SIZE = 4 -export const CASE_TOTAL_COUNT = 6 - -export const CASE_FILTER_INDUSTRIES = [ - '金融科技', - '电商零售', - '教育培训', - '医疗健康', - '硬件电路', - '能源环保', - '房地产', -] as const +/** 收藏列表默认按完成时间倒序 */ +export const CASE_FAVORITE_LIST_SORT = { + orderByColumn: 'completedAt', + isAsc: 'desc', +} as const export const CASE_FILTER_SCALES = [ '全部', @@ -47,80 +41,3 @@ export const DEFAULT_CASE_FILTER: CaseFilterValues = { 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', - }, -] diff --git a/src/hooks/use-case-favorites.ts b/src/hooks/use-case-favorites.ts index 338295e..1332966 100644 --- a/src/hooks/use-case-favorites.ts +++ b/src/hooks/use-case-favorites.ts @@ -1,73 +1,24 @@ -import { computed, ref } from 'vue' -import { CASE_LIBRARY_LIST } from '@/constants/case-library' -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(loadFavoriteIds()) +import { favoriteCase, unfavoriteCase } from '@/api/case-library' +import { ensureLoggedIn } from '@/utils/auth-guard' export function useCaseFavorites() { - const favoriteCases = computed(() => - CASE_LIBRARY_LIST - .filter(item => favoriteIds.value.includes(item.id)) - .map(item => ({ ...item, favorite: true })), - ) + async function toggleFavorite(id: string, currentFavorite: boolean) { + if (!ensureLoggedIn()) + return false - function syncFavoriteIds() { - favoriteIds.value = loadFavoriteIds() - } - - function isFavorite(id: string) { - return favoriteIds.value.includes(id) - } - - function toggleFavorite(id: string) { - if (favoriteIds.value.includes(id)) { - favoriteIds.value = favoriteIds.value.filter(itemId => itemId !== id) + try { + if (currentFavorite) + await unfavoriteCase(id) + else + await favoriteCase(id) + return true } - else { - favoriteIds.value = [...favoriteIds.value, id] + catch { + return false } - - saveFavoriteIds(favoriteIds.value) - } - - function applyFavoritesToList(list: CaseItem[]): CaseItem[] { - return list.map(item => ({ - ...item, - favorite: favoriteIds.value.includes(item.id), - })) } return { - favoriteIds, - favoriteCases, - syncFavoriteIds, - isFavorite, toggleFavorite, - applyFavoritesToList, } } diff --git a/src/hooks/use-case-industries.ts b/src/hooks/use-case-industries.ts new file mode 100644 index 0000000..26555b1 --- /dev/null +++ b/src/hooks/use-case-industries.ts @@ -0,0 +1,34 @@ +import { ref } from 'vue' +import { getCaseLibraryIndustries } from '@/api/case-library' + +const industries = ref([]) +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, + } +} diff --git a/src/pages.json b/src/pages.json index 893cdcb..5f40b8e 100644 --- a/src/pages.json +++ b/src/pages.json @@ -55,6 +55,12 @@ "navigationStyle": "custom" } }, + { + "path": "pages/home-index/project-submission-detail/index", + "style": { + "navigationStyle": "custom" + } + }, { "path": "pages/home-index/requirement-submit/index", "style": { diff --git a/src/pages/Login/user-login/index.vue b/src/pages/Login/user-login/index.vue index ea2a1be..5066ec9 100644 --- a/src/pages/Login/user-login/index.vue +++ b/src/pages/Login/user-login/index.vue @@ -1,7 +1,7 @@ diff --git a/src/pages/case-library/favorites/index.vue b/src/pages/case-library/favorites/index.vue index 2dbe229..21f8852 100644 --- a/src/pages/case-library/favorites/index.vue +++ b/src/pages/case-library/favorites/index.vue @@ -1,31 +1,36 @@ + + + + diff --git a/src/pages/home-index/components/ServiceCard.vue b/src/pages/home-index/components/ServiceCard.vue index 58ed937..516d5c1 100644 --- a/src/pages/home-index/components/ServiceCard.vue +++ b/src/pages/home-index/components/ServiceCard.vue @@ -6,6 +6,13 @@ const props = defineProps<{ }>() 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) return @@ -21,7 +28,7 @@ function handleTap() { :class="[ `service-card--${item.theme}`, { 'service-card--with-illus': item.illus }, - { 'service-card--clickable': item.detailId }, + { 'service-card--clickable': item.detailId || item.submissionId }, ]" @tap="handleTap" > diff --git a/src/pages/home-index/index.scss b/src/pages/home-index/index.scss index 5de53c2..6eed204 100644 --- a/src/pages/home-index/index.scss +++ b/src/pages/home-index/index.scss @@ -128,11 +128,14 @@ .home__section { margin-bottom: 20rpx; - height: 600rpx; width: 711.83rpx; + min-height: 320rpx; + height: auto; background: $color-bg-page; border-radius: 20rpx; - padding-top: 20rpx + padding-top: 20rpx; + padding-bottom: 24rpx; + box-sizing: border-box; } .home__sections { @@ -186,6 +189,14 @@ overflow: visible; } +.home__service-empty { + display: block; + padding: 48rpx 20rpx 24rpx; + font-size: 26rpx; + color: $color-text-secondary; + text-align: center; +} + .home__quick-row { display: flex; justify-content: space-between; diff --git a/src/pages/home-index/index.vue b/src/pages/home-index/index.vue index 9028333..8d03657 100644 --- a/src/pages/home-index/index.vue +++ b/src/pages/home-index/index.vue @@ -1,16 +1,18 @@ + + + + diff --git a/src/types/banner.ts b/src/types/banner.ts new file mode 100644 index 0000000..e87e45c --- /dev/null +++ b/src/types/banner.ts @@ -0,0 +1,7 @@ +export interface BannerItem { + id: number + imageUrl: string + sort?: number + enabled?: number + updateTime?: string | null +} diff --git a/src/types/case-library-api.ts b/src/types/case-library-api.ts new file mode 100644 index 0000000..bf419de --- /dev/null +++ b/src/types/case-library-api.ts @@ -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[] +} diff --git a/src/types/case-library.ts b/src/types/case-library.ts index e9dde65..97999ed 100644 --- a/src/types/case-library.ts +++ b/src/types/case-library.ts @@ -18,13 +18,17 @@ export interface CaseAttachment { id: string name: string icon: string + url?: string +} + +export interface CaseCatalogSection { + id: string + title: string + content: string } export interface CaseDetail extends CaseItem { - background: string - painPoints: string - requirement: string - deliverables: string + catalogs: CaseCatalogSection[] attachments: CaseAttachment[] } diff --git a/src/types/home.ts b/src/types/home.ts index baade56..e4b6875 100644 --- a/src/types/home.ts +++ b/src/types/home.ts @@ -5,6 +5,8 @@ export interface ServiceItem { theme: 'blue' | 'green' | 'pink' | 'orange' illus?: string detailId?: string + /** App 项目提报 id,跳转提报详情 */ + submissionId?: string } export interface QuickEntryItem { diff --git a/src/types/project-submission.ts b/src/types/project-submission.ts new file mode 100644 index 0000000..97afb52 --- /dev/null +++ b/src/types/project-submission.ts @@ -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 +} diff --git a/src/utils/auth-guard.ts b/src/utils/auth-guard.ts index 4ad052d..1c49a74 100644 --- a/src/utils/auth-guard.ts +++ b/src/utils/auth-guard.ts @@ -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' +let redirectingToLogin = false + export function isLoggedIn() { return Boolean(getAccessToken()) } @@ -14,6 +16,26 @@ export function ensureLoggedIn(showToast = true) { if (showToast) uni.showToast({ title: '请先登录', icon: 'none' }) - uni.reLaunch({ url: LOGIN_PAGE }) + redirectToLogin() 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 + }, + }) +} diff --git a/src/utils/auth-storage.ts b/src/utils/auth-storage.ts index 070da75..5f2c78b 100644 --- a/src/utils/auth-storage.ts +++ b/src/utils/auth-storage.ts @@ -1,18 +1,40 @@ +import { AUTH_CLIENT_ID } from '@/constants/config' + const ACCESS_TOKEN_KEY = 'access_token' +const AUTH_CLIENT_ID_KEY = 'auth_client_id' const LOGIN_PHONE_KEY = 'login_phone_number' export function setAccessToken(token: string) { - uni.setStorageSync(ACCESS_TOKEN_KEY, token) + uni.setStorageSync(ACCESS_TOKEN_KEY, String(token).trim()) } 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() { 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) { uni.setStorageSync(LOGIN_PHONE_KEY, phoneNumber) } @@ -27,5 +49,6 @@ export function clearLoginPhoneNumber() { export function clearAuthSession() { clearAccessToken() + clearAuthClientId() clearLoginPhoneNumber() } diff --git a/src/utils/build-case-library-query.ts b/src/utils/build-case-library-query.ts new file mode 100644 index 0000000..8d4a5f9 --- /dev/null +++ b/src/utils/build-case-library-query.ts @@ -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 = { + 近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 +} diff --git a/src/utils/map-case-library.ts b/src/utils/map-case-library.ts new file mode 100644 index 0000000..f8bcd25 --- /dev/null +++ b/src/utils/map-case-library.ts @@ -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 { + const categories = CASE_CATEGORIES.filter(item => item !== '全部') as Exclude[] + if (value && categories.includes(value as Exclude)) + return value as Exclude + 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 { + 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 { + 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), + } + }) +} diff --git a/src/utils/map-project-submission.ts b/src/utils/map-project-submission.ts new file mode 100644 index 0000000..843bbaa --- /dev/null +++ b/src/utils/map-project-submission.ts @@ -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 { + 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), + })) +} diff --git a/src/utils/request.ts b/src/utils/request.ts index 7394e4b..3829c60 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -1,5 +1,6 @@ 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 { url: string @@ -41,12 +42,14 @@ export function request(options: RequestOptions): Promise { return new Promise((resolve, reject) => { const token = getAccessToken() + const clientId = getAuthClientId() uni.request({ url: resolveUrl(url), method, data, header: { 'Content-Type': 'application/json', + ...(clientId ? { clientid: clientId } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...header, }, @@ -64,6 +67,12 @@ export function request(options: RequestOptions): Promise { } const message = getErrorMessage(apiBody, '请求失败') + if (apiBody.code === 401) { + handleUnauthorized(message) + reject(new Error(message)) + return + } + if (showErrorToast) uni.showToast({ title: message, icon: 'none' }) reject(new Error(message)) @@ -74,6 +83,12 @@ export function request(options: RequestOptions): Promise { return } + if (statusCode === 401) { + handleUnauthorized('登录状态异常,请重新登录') + reject(new Error('登录状态异常,请重新登录')) + return + } + const message = `请求失败(${statusCode})` if (showErrorToast) uni.showToast({ title: message, icon: 'none' }) diff --git a/src/utils/resolve-oss-media.ts b/src/utils/resolve-oss-media.ts new file mode 100644 index 0000000..66d4800 --- /dev/null +++ b/src/utils/resolve-oss-media.ts @@ -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) { + const map = new Map() + 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) { + if (!raw) + return undefined + if (isHttpUrl(raw)) + return raw + return urlMap?.get(String(raw)) +} diff --git a/src/utils/resolve-project-submission-media.ts b/src/utils/resolve-project-submission-media.ts new file mode 100644 index 0000000..18e2018 --- /dev/null +++ b/src/utils/resolve-project-submission-media.ts @@ -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 = {} + 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, + } +}