From 67a6cbcd9edbed264b8464ff3f48e5342a9d260f Mon Sep 17 00:00:00 2001 From: liangxu Date: Fri, 10 Jul 2026 20:10:50 +0800 Subject: [PATCH] feat(media-supply): persist favorites with multi-group membership Move media favorites from client state to PostgreSQL with server-side search and pagination. - Store favorites keyed by (tenant, user, group, resource) so a resource can belong to multiple groups; the 500-resource cap counts distinct resources, not memberships. - Add favorite-group CRUD and group/global removal routes, with silent cleanup of delisted or invisible resources on read. - Serve favorite resource details server-side, 10 per page, with name/ID search; favorites-page removal is group-scoped, resources-page removal is global. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/admin-web/src/lib/api.ts | 47 +- .../src/lib/media-supply-favorite-store.ts | 102 ++ .../src/lib/media-supply-favorites.ts | 191 +--- .../src/views/MediaSupplyFavoritesView.vue | 587 +++++++---- .../src/views/MediaSupplyResourcesView.vue | 221 +++-- packages/shared-types/src/index.ts | 23 + server/internal/ops/app/media_supply.go | 98 +- .../internal/shared/swagger/descriptions.go | 35 +- .../tenant/app/media_supply_favorites.go | 919 ++++++++++++++++++ .../tenant/app/media_supply_favorites_test.go | 379 ++++++++ .../tenant/app/media_supply_service.go | 128 ++- .../tenant/transport/media_supply_handler.go | 99 ++ server/internal/tenant/transport/router.go | 7 + ...000_create_media_supply_favorites.down.sql | 3 + ...83000_create_media_supply_favorites.up.sql | 57 ++ 15 files changed, 2378 insertions(+), 518 deletions(-) create mode 100644 apps/admin-web/src/lib/media-supply-favorite-store.ts create mode 100644 server/internal/tenant/app/media_supply_favorites.go create mode 100644 server/internal/tenant/app/media_supply_favorites_test.go create mode 100644 server/migrations/20260710183000_create_media_supply_favorites.down.sql create mode 100644 server/migrations/20260710183000_create_media_supply_favorites.up.sql diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 337e1cf..daaaa5a 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -28,6 +28,7 @@ import type { CreateEnterpriseSiteConnectionRequest, CreateKolPackageRequest, CreateKolPromptRequest, + CreateMediaSupplyFavoriteGroupRequest, CreateMediaSupplyOrderRequest, CreateMediaSupplyOrderResponse, CreatePluginSessionRequest, @@ -87,6 +88,7 @@ import type { KolWorkspaceCard, ListCustomerSupplierMediaResourcesResponse, ListDesktopPublishTasksParams, + ListMediaSupplyFavoriteResourcesParams, ListMediaSupplyOrdersParams, ListMediaSupplyOrdersResponse, ListMediaSupplyWalletLedgersParams, @@ -98,6 +100,7 @@ import type { MaterializeQuestionsRequest, MaterializeQuestionsResult, MediaPlatform, + MediaSupplyFavoriteGroupsResponse, MediaSupplyOrderDetail, MediaSupplySearchOptionsResponse, MediaSupplySessionStatus, @@ -1285,18 +1288,50 @@ export const mediaSupplyApi = { }, ) }, - listResourcesByIds(ids: number[], modelId?: number) { - return apiClient.get( - '/api/tenant/media-supply/resources/by-ids', - { params: { ids: ids.join(','), model_id: modelId } }, - ) - }, searchOptions(modelId?: number) { return apiClient.get( '/api/tenant/media-supply/resources/search-options', { params: { model_id: modelId } }, ) }, + listFavoriteGroups() { + return apiClient.get( + '/api/tenant/media-supply/favorite-groups', + ) + }, + listFavoriteResources(groupId: string, params: ListMediaSupplyFavoriteResourcesParams = {}) { + return apiClient.get( + `/api/tenant/media-supply/favorite-groups/${encodeURIComponent(groupId)}/resources`, + { params }, + ) + }, + createFavoriteGroup(payload: CreateMediaSupplyFavoriteGroupRequest) { + return apiClient.post( + '/api/tenant/media-supply/favorite-groups', + payload, + ) + }, + deleteFavoriteGroup(groupId: string) { + return apiClient.remove( + `/api/tenant/media-supply/favorite-groups/${encodeURIComponent(groupId)}`, + ) + }, + putFavoriteResource(groupId: string, resourceId: number) { + return apiClient.put( + `/api/tenant/media-supply/favorite-groups/${encodeURIComponent(groupId)}/resources/${resourceId}`, + null, + ) + }, + deleteFavoriteResourceFromGroup(groupId: string, resourceId: number) { + return apiClient.remove( + `/api/tenant/media-supply/favorite-groups/${encodeURIComponent(groupId)}/resources/${resourceId}`, + ) + }, + deleteFavoriteResource(resourceId: number) { + return apiClient.remove( + `/api/tenant/media-supply/favorite-resources/${resourceId}`, + ) + }, createOrder(payload: CreateMediaSupplyOrderRequest) { return apiClient.post( '/api/tenant/media-supply/orders', diff --git a/apps/admin-web/src/lib/media-supply-favorite-store.ts b/apps/admin-web/src/lib/media-supply-favorite-store.ts new file mode 100644 index 0000000..99177ef --- /dev/null +++ b/apps/admin-web/src/lib/media-supply-favorite-store.ts @@ -0,0 +1,102 @@ +import type { + MediaSupplyFavoriteGroupRecord, + MediaSupplyFavoriteGroupsResponse, +} from '@geo/shared-types' + +import { mediaSupplyApi } from './api' +import { + createDefaultMediaSupplyFavoriteGroups, + mergeMediaSupplyFavoriteResourceSnapshots, + type MediaSupplyFavoriteGroup, + type MediaSupplyFavoriteResourceInput, +} from './media-supply-favorites' + +export interface PersistedMediaSupplyFavoriteState { + groups: MediaSupplyFavoriteGroup[] + revision: number +} + +export async function loadPersistedMediaSupplyFavoriteGroups(): Promise { + const response = await mediaSupplyApi.listFavoriteGroups() + return favoriteStateFromResponse(response) +} + +export async function createPersistedMediaSupplyFavoriteGroup( + name: string, + currentGroups: MediaSupplyFavoriteGroup[], + resource?: MediaSupplyFavoriteResourceInput, +): Promise { + const response = await mediaSupplyApi.createFavoriteGroup({ + name, + resource_id: resource?.id, + }) + return favoriteStateFromResponse(response, currentGroups, resource) +} + +export async function deletePersistedMediaSupplyFavoriteGroup( + groupId: string, + currentGroups: MediaSupplyFavoriteGroup[], +): Promise { + const response = await mediaSupplyApi.deleteFavoriteGroup(groupId) + return favoriteStateFromResponse(response, currentGroups) +} + +export async function putPersistedMediaSupplyFavoriteResource( + groupId: string, + resource: MediaSupplyFavoriteResourceInput, + currentGroups: MediaSupplyFavoriteGroup[], +): Promise { + const response = await mediaSupplyApi.putFavoriteResource(groupId, resource.id) + return favoriteStateFromResponse(response, currentGroups, resource) +} + +export async function deletePersistedMediaSupplyFavoriteResource( + resourceId: number, + currentGroups: MediaSupplyFavoriteGroup[], +): Promise { + const response = await mediaSupplyApi.deleteFavoriteResource(resourceId) + return favoriteStateFromResponse(response, currentGroups) +} + +export async function deletePersistedMediaSupplyFavoriteResourceFromGroup( + groupId: string, + resourceId: number, + currentGroups: MediaSupplyFavoriteGroup[], +): Promise { + const response = await mediaSupplyApi.deleteFavoriteResourceFromGroup(groupId, resourceId) + return favoriteStateFromResponse(response, currentGroups) +} + +function favoriteStateFromResponse( + response: MediaSupplyFavoriteGroupsResponse, + currentGroups: MediaSupplyFavoriteGroup[] = [], + resource?: MediaSupplyFavoriteResourceInput, +): PersistedMediaSupplyFavoriteState { + const groups = favoriteGroupsFromRecords(response.groups, currentGroups) + return { + groups: resource ? mergeMediaSupplyFavoriteResourceSnapshots(groups, [resource]) : groups, + revision: response.revision, + } +} + +function favoriteGroupsFromRecords( + records: MediaSupplyFavoriteGroupRecord[], + snapshots: MediaSupplyFavoriteGroup[] = [], +): MediaSupplyFavoriteGroup[] { + const resourcesByID = new Map( + snapshots.flatMap((group) => group.resources).map((resource) => [resource.id, resource]), + ) + const groups = records.map((record) => { + const resourceIds = [...new Set(record.resource_ids)] + return { + id: record.id, + name: record.name, + resourceIds, + resources: resourceIds + .map((resourceId) => resourcesByID.get(resourceId)) + .filter((resource) => Boolean(resource)), + updatedAt: record.updated_at, + } as MediaSupplyFavoriteGroup + }) + return groups.length > 0 ? groups : createDefaultMediaSupplyFavoriteGroups() +} diff --git a/apps/admin-web/src/lib/media-supply-favorites.ts b/apps/admin-web/src/lib/media-supply-favorites.ts index 4834e2f..5621378 100644 --- a/apps/admin-web/src/lib/media-supply-favorites.ts +++ b/apps/admin-web/src/lib/media-supply-favorites.ts @@ -28,104 +28,16 @@ export interface MediaSupplyFavoriteGroup { updatedAt: string } -function storageKey(userId?: number | null): string { - return `media_supply_favorite_groups:${userId || 'anonymous'}` -} - -function defaultGroup(): MediaSupplyFavoriteGroup { - return { - id: 'default', - name: '默认分组', - resourceIds: [], - resources: [], - updatedAt: new Date().toISOString(), - } -} - -export function loadMediaSupplyFavoriteGroups(userId?: number | null): MediaSupplyFavoriteGroup[] { - if (typeof window === 'undefined') { - return [defaultGroup()] - } - try { - const parsed = JSON.parse(window.localStorage.getItem(storageKey(userId)) || '[]') as unknown - if (!Array.isArray(parsed)) { - return [defaultGroup()] - } - const groups = parsed - .map((item) => normalizeGroup(item)) - .filter((item): item is MediaSupplyFavoriteGroup => Boolean(item)) - return groups.length > 0 ? groups : [defaultGroup()] - } catch { - return [defaultGroup()] - } -} - -export function saveMediaSupplyFavoriteGroups( - userId: number | null | undefined, - groups: MediaSupplyFavoriteGroup[], -): void { - if (typeof window === 'undefined') { - return - } - window.localStorage.setItem(storageKey(userId), JSON.stringify(groups)) -} - -export function toggleMediaSupplyFavorite( - groups: MediaSupplyFavoriteGroup[], - resourceId: number, - groupId = 'default', - resource?: MediaSupplyFavoriteResourceInput, -): MediaSupplyFavoriteGroup[] { - const now = new Date().toISOString() - const next = groups.length > 0 ? [...groups] : [defaultGroup()] - const index = next.findIndex((group) => group.id === groupId) - if (index < 0) { - next.push({ - id: groupId, +export function createDefaultMediaSupplyFavoriteGroups(): MediaSupplyFavoriteGroup[] { + return [ + { + id: 'default', name: '默认分组', - resourceIds: [resourceId], - resources: compactResourceSnapshots( - [], - resource ? { ...resource, id: resourceId } : undefined, - ), - updatedAt: now, - }) - return next - } - const group = next[index] - const exists = group.resourceIds.includes(resourceId) - next[index] = { - ...group, - resourceIds: exists - ? group.resourceIds.filter((id) => id !== resourceId) - : [...group.resourceIds, resourceId], - resources: exists - ? group.resources.filter((item) => item.id !== resourceId) - : compactResourceSnapshots( - group.resources, - resource ? { ...resource, id: resourceId } : undefined, - ), - updatedAt: now, - } - return next -} - -export function removeMediaSupplyFavorite( - groups: MediaSupplyFavoriteGroup[], - resourceId: number, -): MediaSupplyFavoriteGroup[] { - const now = new Date().toISOString() - return (groups.length > 0 ? groups : [defaultGroup()]).map((group) => { - if (!group.resourceIds.includes(resourceId)) { - return group - } - return { - ...group, - resourceIds: group.resourceIds.filter((id) => id !== resourceId), - resources: group.resources.filter((item) => item.id !== resourceId), - updatedAt: now, - } - }) + resourceIds: [], + resources: [], + updatedAt: new Date().toISOString(), + }, + ] } export function mergeMediaSupplyFavoriteResourceSnapshots( @@ -141,7 +53,7 @@ export function mergeMediaSupplyFavoriteResourceSnapshots( const snapshotById = new Map(snapshots.map((resource) => [resource.id, resource])) return groups.map((group) => { let changed = false - const resources = group.resourceIds + const merged = group.resourceIds .map((resourceId) => { const fresh = snapshotById.get(resourceId) if (fresh) { @@ -152,88 +64,10 @@ export function mergeMediaSupplyFavoriteResourceSnapshots( }) .filter((resource): resource is MediaSupplyFavoriteResourceSnapshot => Boolean(resource)) - if (!changed) { - return group - } - - return { - ...group, - resources, - } + return changed ? { ...group, resources: merged } : group }) } -export function reconcileMediaSupplyFavoriteResources( - groups: MediaSupplyFavoriteGroup[], - resources: MediaSupplyFavoriteResourceInput[], -): MediaSupplyFavoriteGroup[] { - const snapshots = resources - .map((resource) => normalizeResourceSnapshot(resource)) - .filter((resource): resource is MediaSupplyFavoriteResourceSnapshot => Boolean(resource)) - const snapshotById = new Map(snapshots.map((resource) => [resource.id, resource])) - const now = new Date().toISOString() - - return (groups.length > 0 ? groups : [defaultGroup()]).map((group) => { - const resourceIds = group.resourceIds.filter((resourceId) => snapshotById.has(resourceId)) - const changed = - resourceIds.length !== group.resourceIds.length || - resourceIds.some((resourceId, index) => resourceId !== group.resourceIds[index]) - - return { - ...group, - resourceIds, - resources: resourceIds - .map((resourceId) => snapshotById.get(resourceId)) - .filter((resource): resource is MediaSupplyFavoriteResourceSnapshot => Boolean(resource)), - updatedAt: changed ? now : group.updatedAt, - } - }) -} - -function normalizeGroup(value: unknown): MediaSupplyFavoriteGroup | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const record = value as Record - const id = typeof record.id === 'string' && record.id.trim() ? record.id.trim() : '' - const name = typeof record.name === 'string' && record.name.trim() ? record.name.trim() : '' - if (!id || !name) { - return null - } - const normalizedResources = Array.isArray(record.resources) - ? record.resources - .map((item) => normalizeResourceSnapshot(item)) - .filter((item): item is MediaSupplyFavoriteResourceSnapshot => Boolean(item)) - : [] - const resourceIds = Array.isArray(record.resourceIds) - ? record.resourceIds - .map((item) => (typeof item === 'number' ? item : Number(item))) - .filter((item) => Number.isFinite(item) && item > 0) - : [] - const mergedResourceIds = [ - ...new Set([...resourceIds, ...normalizedResources.map((resource) => resource.id)]), - ] - const resources = normalizedResources.filter((resource) => - mergedResourceIds.includes(resource.id), - ) - const updatedAt = - typeof record.updatedAt === 'string' && record.updatedAt.trim() - ? record.updatedAt - : new Date().toISOString() - return { id, name, resourceIds: mergedResourceIds, resources, updatedAt } -} - -function compactResourceSnapshots( - resources: MediaSupplyFavoriteResourceSnapshot[], - resource?: MediaSupplyFavoriteResourceInput, -): MediaSupplyFavoriteResourceSnapshot[] { - const snapshot = resource ? normalizeResourceSnapshot(resource) : null - if (!snapshot) { - return resources - } - return [...resources.filter((item) => item.id !== snapshot.id), snapshot] -} - function normalizeResourceSnapshot(value: unknown): MediaSupplyFavoriteResourceSnapshot | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null @@ -243,11 +77,10 @@ function normalizeResourceSnapshot(value: unknown): MediaSupplyFavoriteResourceS if (!Number.isInteger(id) || id <= 0) { return null } - const name = textValue(record.name) || `媒体 #${id}` return { id, supplier_resource_id: textValue(record.supplier_resource_id), - name, + name: textValue(record.name) || `媒体 #${id}`, status: textValue(record.status), sell_price_cents: numberValue(record.sell_price_cents), resource_url: textValue(record.resource_url), diff --git a/apps/admin-web/src/views/MediaSupplyFavoritesView.vue b/apps/admin-web/src/views/MediaSupplyFavoritesView.vue index e8e6553..dc7f3a5 100644 --- a/apps/admin-web/src/views/MediaSupplyFavoritesView.vue +++ b/apps/admin-web/src/views/MediaSupplyFavoritesView.vue @@ -2,68 +2,78 @@ import { BankOutlined, DeleteOutlined, - PlusOutlined, ReloadOutlined, + SearchOutlined, SendOutlined, } from '@ant-design/icons-vue' import { useQuery } from '@tanstack/vue-query' import { message } from 'ant-design-vue' -import { computed, ref, watch } from 'vue' +import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { useRouter } from 'vue-router' import { mediaSupplyApi } from '@/lib/api' import { formatDateTime } from '@/lib/display' import { - loadMediaSupplyFavoriteGroups, - reconcileMediaSupplyFavoriteResources, - removeMediaSupplyFavorite, - saveMediaSupplyFavoriteGroups, + createPersistedMediaSupplyFavoriteGroup, + deletePersistedMediaSupplyFavoriteGroup, + deletePersistedMediaSupplyFavoriteResourceFromGroup, + loadPersistedMediaSupplyFavoriteGroups, + type PersistedMediaSupplyFavoriteState, +} from '@/lib/media-supply-favorite-store' +import { + createDefaultMediaSupplyFavoriteGroups, type MediaSupplyFavoriteGroup, type MediaSupplyFavoriteResourceSnapshot, } from '@/lib/media-supply-favorites' import { saveMediaSupplySubmitSelection } from '@/lib/media-supply-submit-selection' import { useAuthStore } from '@/stores/auth' -const MODEL_ID_AUTHORITY_NEWS = 1 +const FAVORITE_PAGE_SIZE = 10 +const FAVORITE_SEARCH_DELAY_MS = 300 const authStore = useAuthStore() const router = useRouter() -const groups = ref(loadMediaSupplyFavoriteGroups(authStore.user?.id)) +const groups = ref(createDefaultMediaSupplyFavoriteGroups()) const groupName = ref('') const createGroupModalOpen = ref(false) const activeGroupId = ref(groups.value[0]?.id || 'default') +const favoriteMutationPending = ref(false) +const favoriteRevision = ref(0) +const favoriteGroupsReady = ref(false) +const favoriteSearchInput = ref('') +const favoriteKeyword = ref('') +const favoritePage = ref(1) +let favoriteLoadSequence = 0 +let favoriteSearchTimer: number | null = null -const totalResources = computed(() => - groups.value.reduce((sum, group) => sum + group.resourceIds.length, 0), +const totalResources = computed( + () => new Set(groups.value.flatMap((group) => group.resourceIds)).size, ) -const allResourceIds = computed(() => [ - ...new Set(groups.value.flatMap((group) => group.resourceIds)), -]) const activeGroup = computed( () => groups.value.find((group) => group.id === activeGroupId.value) || groups.value[0], ) -const resourcesById = computed(() => { - const map = new Map() - for (const group of groups.value) { - for (const resource of group.resources) { - map.set(resource.id, resource) - } - } - return map -}) -const activeResources = computed(() => { - const group = activeGroup.value - if (!group) { - return [] - } - return group.resourceIds.map((resourceId) => resourceForId(group, resourceId)) -}) const resourceDetailsQuery = useQuery({ - queryKey: computed(() => ['media-supply', 'favorite-resources', allResourceIds.value.join(',')]), - queryFn: () => mediaSupplyApi.listResourcesByIds(allResourceIds.value, MODEL_ID_AUTHORITY_NEWS), - enabled: computed(() => allResourceIds.value.length > 0), + queryKey: computed(() => [ + 'media-supply', + 'favorite-group-resources', + authStore.user?.id, + activeGroupId.value, + favoriteKeyword.value, + favoritePage.value, + favoriteRevision.value, + ]), + queryFn: () => + mediaSupplyApi.listFavoriteResources(activeGroupId.value, { + keyword: favoriteKeyword.value || undefined, + page: favoritePage.value, + }), + enabled: computed(() => + Boolean(authStore.user?.id && activeGroup.value && favoriteGroupsReady.value), + ), }) +const activeResources = computed(() => resourceDetailsQuery.data.value?.items || []) +const filteredResourceTotal = computed(() => resourceDetailsQuery.data.value?.total || 0) const walletQuery = useQuery({ queryKey: ['media-supply', 'wallet'], @@ -73,48 +83,114 @@ const walletQuery = useQuery({ watch( () => authStore.user?.id, (userId) => { - groups.value = loadMediaSupplyFavoriteGroups(userId) - activeGroupId.value = groups.value[0]?.id || 'default' + favoriteRevision.value = 0 + favoriteGroupsReady.value = false + groups.value = createDefaultMediaSupplyFavoriteGroups() + activeGroupId.value = 'default' + favoriteSearchInput.value = '' + favoriteKeyword.value = '' + favoritePage.value = 1 + void loadFavoriteGroups(userId) }, + { immediate: true }, ) +watch(activeGroupId, () => { + favoritePage.value = 1 +}) + +watch(favoriteSearchInput, (value) => { + if (favoriteSearchTimer !== null) { + window.clearTimeout(favoriteSearchTimer) + } + favoriteSearchTimer = window.setTimeout(() => { + favoriteKeyword.value = value.trim() + favoritePage.value = 1 + favoriteSearchTimer = null + }, FAVORITE_SEARCH_DELAY_MS) +}) + watch( - () => resourceDetailsQuery.data.value?.items, - (items) => { - if (!items || allResourceIds.value.length === 0) { + () => resourceDetailsQuery.data.value?.total, + (total) => { + if (typeof total !== 'number') { return } - const before = allResourceIds.value.length - groups.value = reconcileMediaSupplyFavoriteResources(groups.value, items) - persist() - const removed = before - allResourceIds.value.length - if (removed > 0) { - message.info(`已移除 ${removed} 个已下架常发媒体`) + const lastPage = Math.max(1, Math.ceil(total / FAVORITE_PAGE_SIZE)) + if (favoritePage.value > lastPage) { + favoritePage.value = lastPage } }, ) -function refresh(): void { - groups.value = loadMediaSupplyFavoriteGroups(authStore.user?.id) - if (!groups.value.some((group) => group.id === activeGroupId.value)) { - activeGroupId.value = groups.value[0]?.id || 'default' +onBeforeUnmount(() => { + if (favoriteSearchTimer !== null) { + window.clearTimeout(favoriteSearchTimer) + } +}) + +async function loadFavoriteGroups(userId?: number): Promise { + const sequence = ++favoriteLoadSequence + if (!userId) { + groups.value = createDefaultMediaSupplyFavoriteGroups() + activeGroupId.value = 'default' + favoriteGroupsReady.value = false + return + } + try { + const loaded = await loadPersistedMediaSupplyFavoriteGroups() + if (sequence !== favoriteLoadSequence || authStore.user?.id !== userId) { + return + } + applyFavoriteState(loaded, userId) + if (!groups.value.some((group) => group.id === activeGroupId.value)) { + activeGroupId.value = groups.value[0]?.id || 'default' + } + favoriteGroupsReady.value = true + } catch { + if (sequence === favoriteLoadSequence) { + favoriteGroupsReady.value = false + message.error('常发媒体读取失败,请稍后重试') + } } - void resourceDetailsQuery.refetch() } -function persist(): void { - saveMediaSupplyFavoriteGroups(authStore.user?.id, groups.value) +function applyFavoriteState( + state: PersistedMediaSupplyFavoriteState, + expectedUserId: number | undefined, +): boolean { + if (expectedUserId !== authStore.user?.id || state.revision < favoriteRevision.value) { + return false + } + favoriteRevision.value = state.revision + groups.value = state.groups + return true +} + +async function refresh(): Promise { + await loadFavoriteGroups(authStore.user?.id) + await resourceDetailsQuery.refetch() +} + +function applyFavoriteSearch(): void { + if (favoriteSearchTimer !== null) { + window.clearTimeout(favoriteSearchTimer) + favoriteSearchTimer = null + } + favoriteKeyword.value = favoriteSearchInput.value.trim() + favoritePage.value = 1 +} + +function changeFavoritePage(page: number): void { + favoritePage.value = Math.max(1, page) } function openCreateGroup(): void { - if (groupName.value.trim()) { - createGroup() - return - } + groupName.value = '' createGroupModalOpen.value = true } -function createGroup(): void { +async function createGroup(): Promise { const name = groupName.value.trim() if (!name) { message.warning('请输入分组名称') @@ -124,37 +200,70 @@ function createGroup(): void { message.warning('分组名称已存在') return } - const id = `group_${Date.now()}` - groups.value = [ - ...groups.value, - { - id, - name, - resourceIds: [], - resources: [], - updatedAt: new Date().toISOString(), - }, - ] - activeGroupId.value = id - groupName.value = '' - createGroupModalOpen.value = false - persist() - message.success('常发分组已创建') -} - -function removeGroup(groupId: string): void { - groups.value = groups.value.filter((group) => group.id !== groupId) - if (groups.value.length === 0) { - groups.value = loadMediaSupplyFavoriteGroups(authStore.user?.id) + if (favoriteMutationPending.value) { + return + } + favoriteMutationPending.value = true + const userId = authStore.user?.id + try { + const previousIDs = new Set(groups.value.map((group) => group.id)) + const next = await createPersistedMediaSupplyFavoriteGroup(name, groups.value) + applyFavoriteState(next, userId) + activeGroupId.value = groups.value.find((group) => !previousIDs.has(group.id))?.id || 'default' + groupName.value = '' + createGroupModalOpen.value = false + message.success('常发分组已创建') + } catch { + message.error('常发分组创建失败,请稍后重试') + } finally { + favoriteMutationPending.value = false } - activeGroupId.value = groups.value[0]?.id || 'default' - persist() } -function removeResource(groupId: string, resourceId: number): void { - groups.value = removeMediaSupplyFavoriteForGroup(groups.value, groupId, resourceId) - persist() - message.success('已移出常发分组') +async function removeGroup(groupId: string): Promise { + if (favoriteMutationPending.value) { + return + } + favoriteMutationPending.value = true + const userId = authStore.user?.id + try { + applyFavoriteState(await deletePersistedMediaSupplyFavoriteGroup(groupId, groups.value), userId) + activeGroupId.value = groups.value[0]?.id || 'default' + } catch { + message.error('常发分组删除失败,请稍后重试') + } finally { + favoriteMutationPending.value = false + } +} + +async function removeResource(resourceId: number): Promise { + if (favoriteMutationPending.value) { + return + } + favoriteMutationPending.value = true + const userId = authStore.user?.id + const groupId = activeGroup.value?.id + if (!groupId) { + favoriteMutationPending.value = false + return + } + const moveToPreviousPage = favoritePage.value > 1 && activeResources.value.length === 1 + try { + applyFavoriteState( + await deletePersistedMediaSupplyFavoriteResourceFromGroup(groupId, resourceId, groups.value), + userId, + ) + if (moveToPreviousPage) { + favoritePage.value-- + } + await nextTick() + await resourceDetailsQuery.refetch() + message.success('已移出常发分组') + } catch { + message.error('移出常发分组失败,请稍后重试') + } finally { + favoriteMutationPending.value = false + } } async function goSubmitPage(resource: MediaSupplyFavoriteResourceSnapshot): Promise { @@ -189,36 +298,6 @@ async function goSubmitPage(resource: MediaSupplyFavoriteResourceSnapshot): Prom void router.push({ name: 'media-supply-submit' }) } -function removeMediaSupplyFavoriteForGroup( - currentGroups: MediaSupplyFavoriteGroup[], - groupId: string, - resourceId: number, -): MediaSupplyFavoriteGroup[] { - return currentGroups.map((group) => - group.id === groupId ? removeMediaSupplyFavorite([group], resourceId)[0] || group : group, - ) -} - -function resourceForId( - group: MediaSupplyFavoriteGroup, - resourceId: number, -): MediaSupplyFavoriteResourceSnapshot { - return ( - group.resources.find((resource) => resource.id === resourceId) || - resourcesById.value.get(resourceId) || { - id: resourceId, - name: `媒体 #${resourceId}`, - } - ) -} - -function formatMoney(cents?: number | null): string { - if (typeof cents !== 'number' || !Number.isFinite(cents)) { - return '--' - } - return `¥${(cents / 100).toFixed(2)}` -} - function formatSellPrice(cents?: number | null): string { if (typeof cents !== 'number' || !Number.isFinite(cents)) { return '--' @@ -256,14 +335,6 @@ function isValidUrl(value?: string | null): boolean {
- - - -
-
-
- -
- - {{ resource.name }} - - {{ resource.name }} -
- {{ displayValue(resource.channel_type) }} - {{ displayValue(resource.region) }} - - 百度 {{ displayValue(resource.baidu_weight) }} - - {{ displayValue(resource.delivery_speed) }} -
-

{{ displayValue(resource.resource_remark) }}

-
-
- -
- {{ formatSellPrice(resource.sell_price_cents) }} - - ID: {{ resource.supplier_resource_id || `#${resource.id}` }} - -
- - -
-
-
+ - + +
+ +
+
+ 常发媒体加载失败 + +
+ @@ -378,7 +496,14 @@ function isValidUrl(value?: string | null): boolean { - +
@@ -452,27 +577,6 @@ function isValidUrl(value?: string | null): boolean { gap: 10px; } -.frequent-toolbar__input { - width: 240px; -} - -.frequent-toolbar__input :deep(.ant-input-affine-wrapper) { - border-radius: 6px !important; - border-color: #d0d5dd !important; - height: 36px !important; - padding: 4px 11px !important; - transition: all 0.2s ease !important; -} - -.frequent-toolbar__input :deep(.ant-input-affine-wrapper:hover), -.frequent-toolbar__input :deep(.ant-input-affine-wrapper-focused) { - border-color: #ff1831 !important; -} - -.frequent-toolbar__input :deep(.ant-input-affine-wrapper-focused) { - box-shadow: 0 0 0 3px rgba(255, 24, 49, 0.12) !important; -} - .toolbar-btn-ok { height: 36px; padding: 0 16px; @@ -655,6 +759,43 @@ function isValidUrl(value?: string | null): boolean { font-size: 12px; } +.group-panel__search { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 38px; + margin-bottom: 16px; +} + +.group-panel__search :deep(.ant-input-affix-wrapper) { + width: min(100%, 420px); + min-height: 36px; + border-color: #d0d5dd; + border-radius: 6px; + box-shadow: none; +} + +.group-panel__search :deep(.ant-input-affix-wrapper:hover), +.group-panel__search :deep(.ant-input-affix-wrapper-focused) { + border-color: #ff1831; +} + +.group-panel__search :deep(.ant-input-affix-wrapper-focused) { + box-shadow: 0 0 0 3px rgba(255, 24, 49, 0.12); +} + +.group-panel__search :deep(.anticon-search) { + color: #98a2b3; +} + +.group-panel__search > span { + flex: 0 0 auto; + color: #64748b; + font-size: 12px; + font-variant-numeric: tabular-nums; +} + .delete-group-btn { display: inline-flex; align-items: center; @@ -683,6 +824,52 @@ function isValidUrl(value?: string | null): boolean { gap: 12px; } +.resource-state { + min-height: 280px; + padding: 20px 4px; +} + +.resource-state--error { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 14px; + color: #64748b; + font-size: 13px; +} + +.resource-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 48px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #f1f5f9; +} + +.resource-pagination > span { + color: #64748b; + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.resource-pagination :deep(.ant-pagination-item), +.resource-pagination :deep(.ant-pagination-prev), +.resource-pagination :deep(.ant-pagination-next) { + border-radius: 4px; +} + +.resource-pagination :deep(.ant-pagination-item-active) { + border-color: #ff1831; +} + +.resource-pagination :deep(.ant-pagination-item-active a) { + color: #ff1831; +} + .resource-card { display: grid; grid-template-columns: minmax(0, 1fr) 188px; @@ -962,10 +1149,20 @@ a.resource-name:hover { justify-content: space-between; } - .frequent-toolbar__input { + .group-panel__search, + .resource-pagination { + align-items: stretch; + flex-direction: column; + } + + .group-panel__search :deep(.ant-input-affix-wrapper) { width: 100%; } + .resource-pagination :deep(.ant-pagination) { + align-self: flex-end; + } + .resource-card { grid-template-columns: 1fr; } diff --git a/apps/admin-web/src/views/MediaSupplyResourcesView.vue b/apps/admin-web/src/views/MediaSupplyResourcesView.vue index d162a8f..a3ad7cd 100644 --- a/apps/admin-web/src/views/MediaSupplyResourcesView.vue +++ b/apps/admin-web/src/views/MediaSupplyResourcesView.vue @@ -18,11 +18,14 @@ import { useRouter } from 'vue-router' import { mediaSupplyApi } from '@/lib/api' import { formatDateTime } from '@/lib/display' import { - loadMediaSupplyFavoriteGroups, - reconcileMediaSupplyFavoriteResources, - removeMediaSupplyFavorite, - saveMediaSupplyFavoriteGroups, - toggleMediaSupplyFavorite, + createPersistedMediaSupplyFavoriteGroup, + deletePersistedMediaSupplyFavoriteResource, + loadPersistedMediaSupplyFavoriteGroups, + putPersistedMediaSupplyFavoriteResource, + type PersistedMediaSupplyFavoriteState, +} from '@/lib/media-supply-favorite-store' +import { + createDefaultMediaSupplyFavoriteGroups, type MediaSupplyFavoriteGroup, } from '@/lib/media-supply-favorites' import { saveMediaSupplySubmitSelection } from '@/lib/media-supply-submit-selection' @@ -85,10 +88,11 @@ const pendingFavoriteResource = ref(null) const selectedFavoriteGroupId = ref('') const newFavoriteGroupName = ref('') const sortMode = ref('default') -const favoriteGroups = ref( - loadMediaSupplyFavoriteGroups(authStore.user?.id), -) +const favoriteGroups = ref(createDefaultMediaSupplyFavoriteGroups()) const favoriteGroupOptions = computed(() => favoriteGroups.value) +const favoriteMutationPending = ref(false) +const favoriteRevision = ref(0) +let favoriteLoadSequence = 0 const fallbackSearchGroups: MediaSupplySearchOptionGroup[] = [ { @@ -247,8 +251,11 @@ const fallbackSearchGroups: MediaSupplySearchOptionGroup[] = [ watch( () => authStore.user?.id, (userId) => { - favoriteGroups.value = loadMediaSupplyFavoriteGroups(userId) + favoriteRevision.value = 0 + favoriteGroups.value = createDefaultMediaSupplyFavoriteGroups() + void loadFavoriteGroups(userId) }, + { immediate: true }, ) const resourcesQuery = useQuery({ @@ -310,19 +317,6 @@ const ledgerQuery = useQuery({ queryFn: () => mediaSupplyApi.listWalletLedgers({ page: ledgerPage.value, page_size: 10 }), enabled: computed(() => ledgerDrawerOpen.value), }) -const favoriteReconcileInFlight = ref(false) - -watch( - () => resourcesQuery.data.value?.items, - (items) => { - const favoriteIds = [...new Set(favoriteGroups.value.flatMap((group) => group.resourceIds))] - if (!items || favoriteIds.length === 0) { - return - } - void reconcileFavoriteResources() - }, -) - const resources = computed(() => (resourcesQuery.data.value?.items ?? []).map(toResourceRow), ) @@ -413,10 +407,43 @@ const ledgerColumns = [ { title: '关联', key: 'order_title', dataIndex: 'order_title' }, ] as TableColumnsType<(typeof ledgerRows.value)[number]> -function refresh(): void { - void resourcesQuery.refetch() - void walletQuery.refetch() - void reconcileFavoriteResources() +async function loadFavoriteGroups(userId?: number): Promise { + const sequence = ++favoriteLoadSequence + if (!userId) { + favoriteGroups.value = createDefaultMediaSupplyFavoriteGroups() + return + } + try { + const loaded = await loadPersistedMediaSupplyFavoriteGroups() + if (sequence !== favoriteLoadSequence || authStore.user?.id !== userId) { + return + } + applyFavoriteState(loaded, userId) + } catch { + if (sequence === favoriteLoadSequence) { + message.error('常发媒体读取失败,请稍后重试') + } + } +} + +function applyFavoriteState( + state: PersistedMediaSupplyFavoriteState, + expectedUserId: number | undefined, +): boolean { + if (expectedUserId !== authStore.user?.id || state.revision < favoriteRevision.value) { + return false + } + favoriteRevision.value = state.revision + favoriteGroups.value = state.groups + return true +} + +async function refresh(): Promise { + await Promise.all([ + resourcesQuery.refetch(), + walletQuery.refetch(), + loadFavoriteGroups(authStore.user?.id), + ]) } function applyFilters(): void { @@ -424,24 +451,6 @@ function applyFilters(): void { void resourcesQuery.refetch() } -function resetFilters(): void { - filters.keyword = '' - filters.channelType = '' - filters.portal = '' - filters.region = '' - filters.inclusionEffect = '' - filters.specialIndustry = '' - filters.linkType = '' - filters.deliverySpeed = '' - filters.aiInclude = '' - filters.otherOption = '' - filters.minPrice = '' - filters.maxPrice = '' - filters.remarkKeyword = '' - sortMode.value = 'default' - applyFilters() -} - function setSortMode(value: string): void { if (sortMode.value === value) { return @@ -490,11 +499,24 @@ function applyPricePreset(min: string, max: string): void { applyFilters() } -function toggleFavorite(resource: MediaSupplyResourceRow): void { +async function toggleFavorite(resource: MediaSupplyResourceRow): Promise { if (favoriteResourceIds.value.has(resource.id)) { - favoriteGroups.value = removeMediaSupplyFavorite(favoriteGroups.value, resource.id) - message.success('已移出常发分组') - saveMediaSupplyFavoriteGroups(authStore.user?.id, favoriteGroups.value) + if (favoriteMutationPending.value) { + return + } + favoriteMutationPending.value = true + const userId = authStore.user?.id + try { + applyFavoriteState( + await deletePersistedMediaSupplyFavoriteResource(resource.id, favoriteGroups.value), + userId, + ) + message.success('已移出常发分组') + } catch { + message.error('移出常发分组失败,请稍后重试') + } finally { + favoriteMutationPending.value = false + } return } pendingFavoriteResource.value = resource @@ -503,63 +525,37 @@ function toggleFavorite(resource: MediaSupplyResourceRow): void { favoriteModalOpen.value = true } -async function reconcileFavoriteResources(): Promise { - if (favoriteReconcileInFlight.value) { - return - } - const favoriteIds = [...new Set(favoriteGroups.value.flatMap((group) => group.resourceIds))] - if (favoriteIds.length === 0) { - return - } - favoriteReconcileInFlight.value = true - try { - const detail = await mediaSupplyApi.listResourcesByIds(favoriteIds, MODEL_ID_AUTHORITY_NEWS) - const before = favoriteIds.length - favoriteGroups.value = reconcileMediaSupplyFavoriteResources(favoriteGroups.value, detail.items) - if (before !== favoriteGroups.value.flatMap((group) => group.resourceIds).length) { - saveMediaSupplyFavoriteGroups(authStore.user?.id, favoriteGroups.value) - } - } finally { - favoriteReconcileInFlight.value = false - } -} - -function confirmAddFavorite(): void { +async function confirmAddFavorite(): Promise { const resource = pendingFavoriteResource.value if (!resource) { favoriteModalOpen.value = false return } - let groupId = selectedFavoriteGroupId.value - const newGroupName = newFavoriteGroupName.value.trim() - if (newGroupName) { - groupId = `group_${Date.now()}` - favoriteGroups.value = [ - ...favoriteGroups.value, - { - id: groupId, - name: newGroupName, - resourceIds: [], - resources: [], - updatedAt: new Date().toISOString(), - }, - ] + if (favoriteMutationPending.value) { + return } - if (!groupId) { + const newGroupName = newFavoriteGroupName.value.trim() + const groupId = selectedFavoriteGroupId.value + if (!newGroupName && !groupId) { message.warning('请选择分组或新建分组') return } - favoriteGroups.value = toggleMediaSupplyFavorite( - favoriteGroups.value, - resource.id, - groupId, - resource, - ) - saveMediaSupplyFavoriteGroups(authStore.user?.id, favoriteGroups.value) - message.success('已加入常发分组') - favoriteModalOpen.value = false - pendingFavoriteResource.value = null - newFavoriteGroupName.value = '' + favoriteMutationPending.value = true + const userId = authStore.user?.id + try { + const next = newGroupName + ? await createPersistedMediaSupplyFavoriteGroup(newGroupName, favoriteGroups.value, resource) + : await putPersistedMediaSupplyFavoriteResource(groupId, resource, favoriteGroups.value) + applyFavoriteState(next, userId) + message.success('已加入常发分组') + favoriteModalOpen.value = false + pendingFavoriteResource.value = null + newFavoriteGroupName.value = '' + } catch { + message.error('加入常发分组失败,请稍后重试') + } finally { + favoriteMutationPending.value = false + } } function statusMeta(status: string): { label: string; color: string } { @@ -810,7 +806,12 @@ function toResourceRow(item: CustomerSupplierMediaResource): MediaSupplyResource