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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ListCustomerSupplierMediaResourcesResponse>(
|
||||
'/api/tenant/media-supply/resources/by-ids',
|
||||
{ params: { ids: ids.join(','), model_id: modelId } },
|
||||
)
|
||||
},
|
||||
searchOptions(modelId?: number) {
|
||||
return apiClient.get<MediaSupplySearchOptionsResponse>(
|
||||
'/api/tenant/media-supply/resources/search-options',
|
||||
{ params: { model_id: modelId } },
|
||||
)
|
||||
},
|
||||
listFavoriteGroups() {
|
||||
return apiClient.get<MediaSupplyFavoriteGroupsResponse>(
|
||||
'/api/tenant/media-supply/favorite-groups',
|
||||
)
|
||||
},
|
||||
listFavoriteResources(groupId: string, params: ListMediaSupplyFavoriteResourcesParams = {}) {
|
||||
return apiClient.get<ListCustomerSupplierMediaResourcesResponse>(
|
||||
`/api/tenant/media-supply/favorite-groups/${encodeURIComponent(groupId)}/resources`,
|
||||
{ params },
|
||||
)
|
||||
},
|
||||
createFavoriteGroup(payload: CreateMediaSupplyFavoriteGroupRequest) {
|
||||
return apiClient.post<MediaSupplyFavoriteGroupsResponse, CreateMediaSupplyFavoriteGroupRequest>(
|
||||
'/api/tenant/media-supply/favorite-groups',
|
||||
payload,
|
||||
)
|
||||
},
|
||||
deleteFavoriteGroup(groupId: string) {
|
||||
return apiClient.remove<MediaSupplyFavoriteGroupsResponse>(
|
||||
`/api/tenant/media-supply/favorite-groups/${encodeURIComponent(groupId)}`,
|
||||
)
|
||||
},
|
||||
putFavoriteResource(groupId: string, resourceId: number) {
|
||||
return apiClient.put<MediaSupplyFavoriteGroupsResponse, null>(
|
||||
`/api/tenant/media-supply/favorite-groups/${encodeURIComponent(groupId)}/resources/${resourceId}`,
|
||||
null,
|
||||
)
|
||||
},
|
||||
deleteFavoriteResourceFromGroup(groupId: string, resourceId: number) {
|
||||
return apiClient.remove<MediaSupplyFavoriteGroupsResponse>(
|
||||
`/api/tenant/media-supply/favorite-groups/${encodeURIComponent(groupId)}/resources/${resourceId}`,
|
||||
)
|
||||
},
|
||||
deleteFavoriteResource(resourceId: number) {
|
||||
return apiClient.remove<MediaSupplyFavoriteGroupsResponse>(
|
||||
`/api/tenant/media-supply/favorite-resources/${resourceId}`,
|
||||
)
|
||||
},
|
||||
createOrder(payload: CreateMediaSupplyOrderRequest) {
|
||||
return apiClient.post<CreateMediaSupplyOrderResponse, CreateMediaSupplyOrderRequest>(
|
||||
'/api/tenant/media-supply/orders',
|
||||
|
||||
@@ -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<PersistedMediaSupplyFavoriteState> {
|
||||
const response = await mediaSupplyApi.listFavoriteGroups()
|
||||
return favoriteStateFromResponse(response)
|
||||
}
|
||||
|
||||
export async function createPersistedMediaSupplyFavoriteGroup(
|
||||
name: string,
|
||||
currentGroups: MediaSupplyFavoriteGroup[],
|
||||
resource?: MediaSupplyFavoriteResourceInput,
|
||||
): Promise<PersistedMediaSupplyFavoriteState> {
|
||||
const response = await mediaSupplyApi.createFavoriteGroup({
|
||||
name,
|
||||
resource_id: resource?.id,
|
||||
})
|
||||
return favoriteStateFromResponse(response, currentGroups, resource)
|
||||
}
|
||||
|
||||
export async function deletePersistedMediaSupplyFavoriteGroup(
|
||||
groupId: string,
|
||||
currentGroups: MediaSupplyFavoriteGroup[],
|
||||
): Promise<PersistedMediaSupplyFavoriteState> {
|
||||
const response = await mediaSupplyApi.deleteFavoriteGroup(groupId)
|
||||
return favoriteStateFromResponse(response, currentGroups)
|
||||
}
|
||||
|
||||
export async function putPersistedMediaSupplyFavoriteResource(
|
||||
groupId: string,
|
||||
resource: MediaSupplyFavoriteResourceInput,
|
||||
currentGroups: MediaSupplyFavoriteGroup[],
|
||||
): Promise<PersistedMediaSupplyFavoriteState> {
|
||||
const response = await mediaSupplyApi.putFavoriteResource(groupId, resource.id)
|
||||
return favoriteStateFromResponse(response, currentGroups, resource)
|
||||
}
|
||||
|
||||
export async function deletePersistedMediaSupplyFavoriteResource(
|
||||
resourceId: number,
|
||||
currentGroups: MediaSupplyFavoriteGroup[],
|
||||
): Promise<PersistedMediaSupplyFavoriteState> {
|
||||
const response = await mediaSupplyApi.deleteFavoriteResource(resourceId)
|
||||
return favoriteStateFromResponse(response, currentGroups)
|
||||
}
|
||||
|
||||
export async function deletePersistedMediaSupplyFavoriteResourceFromGroup(
|
||||
groupId: string,
|
||||
resourceId: number,
|
||||
currentGroups: MediaSupplyFavoriteGroup[],
|
||||
): Promise<PersistedMediaSupplyFavoriteState> {
|
||||
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()
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
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),
|
||||
|
||||
@@ -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<MediaSupplyFavoriteGroup[]>(loadMediaSupplyFavoriteGroups(authStore.user?.id))
|
||||
const groups = ref<MediaSupplyFavoriteGroup[]>(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<number, MediaSupplyFavoriteResourceSnapshot>()
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
@@ -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 {
|
||||
</div>
|
||||
|
||||
<div class="frequent-toolbar__actions">
|
||||
<a-input
|
||||
v-model:value="groupName"
|
||||
placeholder="新建常发分组名称"
|
||||
class="frequent-toolbar__input"
|
||||
@press-enter="createGroup"
|
||||
>
|
||||
<template #prefix><PlusOutlined style="color: #98a2b3" /></template>
|
||||
</a-input>
|
||||
<button type="button" class="toolbar-btn-ok" @click="openCreateGroup">新建分组</button>
|
||||
<button type="button" class="toolbar-btn-refresh" @click="refresh">
|
||||
<ReloadOutlined :class="{ 'refresh-spinning': resourceDetailsQuery.isFetching.value }" />
|
||||
@@ -301,6 +372,7 @@ function isValidUrl(value?: string | null): boolean {
|
||||
v-if="activeGroup.id !== 'default'"
|
||||
type="button"
|
||||
class="delete-group-btn"
|
||||
:disabled="favoriteMutationPending"
|
||||
@click="removeGroup(activeGroup.id)"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
@@ -308,58 +380,104 @@ function isValidUrl(value?: string | null): boolean {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="activeResources.length > 0" class="resource-list">
|
||||
<article v-for="resource in activeResources" :key="resource.id" class="resource-card">
|
||||
<div class="resource-card__main">
|
||||
<span class="resource-icon"><BankOutlined /></span>
|
||||
<div class="resource-card__content">
|
||||
<a
|
||||
v-if="isValidUrl(resource.resource_url)"
|
||||
:href="resource.resource_url || undefined"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="resource-name"
|
||||
>
|
||||
{{ resource.name }}
|
||||
</a>
|
||||
<strong v-else class="resource-name">{{ resource.name }}</strong>
|
||||
<div class="resource-meta">
|
||||
<span>{{ displayValue(resource.channel_type) }}</span>
|
||||
<span>{{ displayValue(resource.region) }}</span>
|
||||
<span
|
||||
class="resource-meta--weight"
|
||||
v-if="resource.baidu_weight !== null && resource.baidu_weight !== undefined"
|
||||
>
|
||||
百度 {{ displayValue(resource.baidu_weight) }}
|
||||
</span>
|
||||
<span>{{ displayValue(resource.delivery_speed) }}</span>
|
||||
</div>
|
||||
<p>{{ displayValue(resource.resource_remark) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="resource-card__side">
|
||||
<strong>{{ formatSellPrice(resource.sell_price_cents) }}</strong>
|
||||
<span class="resource-id-text">
|
||||
ID: {{ resource.supplier_resource_id || `#${resource.id}` }}
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" class="card-btn-submit" @click="goSubmitPage(resource)">
|
||||
<SendOutlined />
|
||||
投稿
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="card-btn-remove"
|
||||
@click="activeGroup && removeResource(activeGroup.id, resource.id)"
|
||||
>
|
||||
移出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div class="group-panel__search">
|
||||
<a-input
|
||||
v-model:value="favoriteSearchInput"
|
||||
allow-clear
|
||||
aria-label="搜索常发媒体"
|
||||
placeholder="搜索媒体名称或媒体 ID"
|
||||
@press-enter="applyFavoriteSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<span v-if="favoriteKeyword && !resourceDetailsQuery.isLoading.value">
|
||||
{{ filteredResourceTotal }} 条结果
|
||||
</span>
|
||||
</div>
|
||||
<a-empty v-else description="还没有常发媒体" />
|
||||
|
||||
<div v-if="resourceDetailsQuery.isLoading.value" class="resource-state">
|
||||
<a-skeleton active :paragraph="{ rows: 6 }" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="resourceDetailsQuery.isError.value"
|
||||
class="resource-state resource-state--error"
|
||||
>
|
||||
<span>常发媒体加载失败</span>
|
||||
<button type="button" class="toolbar-btn-refresh" @click="resourceDetailsQuery.refetch()">
|
||||
<ReloadOutlined />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="activeResources.length > 0" class="resource-list">
|
||||
<article v-for="resource in activeResources" :key="resource.id" class="resource-card">
|
||||
<div class="resource-card__main">
|
||||
<span class="resource-icon"><BankOutlined /></span>
|
||||
<div class="resource-card__content">
|
||||
<a
|
||||
v-if="isValidUrl(resource.resource_url)"
|
||||
:href="resource.resource_url || undefined"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="resource-name"
|
||||
>
|
||||
{{ resource.name }}
|
||||
</a>
|
||||
<strong v-else class="resource-name">{{ resource.name }}</strong>
|
||||
<div class="resource-meta">
|
||||
<span>{{ displayValue(resource.channel_type) }}</span>
|
||||
<span>{{ displayValue(resource.region) }}</span>
|
||||
<span
|
||||
v-if="resource.baidu_weight !== null && resource.baidu_weight !== undefined"
|
||||
class="resource-meta--weight"
|
||||
>
|
||||
百度 {{ displayValue(resource.baidu_weight) }}
|
||||
</span>
|
||||
<span>{{ displayValue(resource.delivery_speed) }}</span>
|
||||
</div>
|
||||
<p>{{ displayValue(resource.resource_remark) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="resource-card__side">
|
||||
<strong>{{ formatSellPrice(resource.sell_price_cents) }}</strong>
|
||||
<span class="resource-id-text">
|
||||
ID: {{ resource.supplier_resource_id || `#${resource.id}` }}
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" class="card-btn-submit" @click="goSubmitPage(resource)">
|
||||
<SendOutlined />
|
||||
投稿
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="card-btn-remove"
|
||||
:disabled="favoriteMutationPending"
|
||||
@click="removeResource(resource.id)"
|
||||
>
|
||||
移出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<a-empty
|
||||
v-else
|
||||
:description="favoriteKeyword ? '没有匹配的常发媒体' : '还没有常发媒体'"
|
||||
/>
|
||||
|
||||
<footer v-if="filteredResourceTotal > 0" class="resource-pagination">
|
||||
<span>共 {{ filteredResourceTotal }} 条</span>
|
||||
<a-pagination
|
||||
:current="favoritePage"
|
||||
:page-size="FAVORITE_PAGE_SIZE"
|
||||
:show-size-changer="false"
|
||||
:total="filteredResourceTotal"
|
||||
show-less-items
|
||||
@change="changeFavoritePage"
|
||||
/>
|
||||
</footer>
|
||||
</template>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -378,7 +496,14 @@ function isValidUrl(value?: string | null): boolean {
|
||||
<button type="button" class="modal-btn-cancel" @click="createGroupModalOpen = false">
|
||||
取消
|
||||
</button>
|
||||
<button type="button" class="modal-btn-ok" @click="createGroup">确认创建</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-btn-ok"
|
||||
:disabled="favoriteMutationPending"
|
||||
@click="createGroup"
|
||||
>
|
||||
确认创建
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<MediaSupplyResourceRow | null>(null)
|
||||
const selectedFavoriteGroupId = ref('')
|
||||
const newFavoriteGroupName = ref('')
|
||||
const sortMode = ref('default')
|
||||
const favoriteGroups = ref<MediaSupplyFavoriteGroup[]>(
|
||||
loadMediaSupplyFavoriteGroups(authStore.user?.id),
|
||||
)
|
||||
const favoriteGroups = ref<MediaSupplyFavoriteGroup[]>(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<MediaSupplyResourceRow[]>(() =>
|
||||
(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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="media-name-cell">
|
||||
<button class="favorite-btn" type="button" @click.stop="toggleFavorite(record)">
|
||||
<button
|
||||
class="favorite-btn"
|
||||
type="button"
|
||||
:disabled="favoriteMutationPending"
|
||||
@click.stop="toggleFavorite(record)"
|
||||
>
|
||||
<StarFilled v-if="favoriteResourceIds.has(record.id)" />
|
||||
<StarOutlined v-else />
|
||||
</button>
|
||||
@@ -869,7 +870,12 @@ function toResourceRow(item: CustomerSupplierMediaResource): MediaSupplyResource
|
||||
<button type="button" class="submit-row-btn" @click="goSubmitPage(record)">
|
||||
投稿
|
||||
</button>
|
||||
<button type="button" class="favorite-row-btn" @click="toggleFavorite(record)">
|
||||
<button
|
||||
type="button"
|
||||
class="favorite-row-btn"
|
||||
:disabled="favoriteMutationPending"
|
||||
@click="toggleFavorite(record)"
|
||||
>
|
||||
{{ favoriteResourceIds.has(record.id) ? '移出常发' : '加入常发' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -920,25 +926,25 @@ function toResourceRow(item: CustomerSupplierMediaResource): MediaSupplyResource
|
||||
<div class="favorite-modal__resource">
|
||||
<div class="favorite-modal__resource-header">
|
||||
<strong>{{ pendingFavoriteResource?.name }}</strong>
|
||||
<span class="resource-price" v-if="pendingFavoriteResource?.sell_price_cents">
|
||||
<span v-if="pendingFavoriteResource?.sell_price_cents" class="resource-price">
|
||||
{{ formatSellPrice(pendingFavoriteResource.sell_price_cents) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="favorite-modal__resource-meta" v-if="pendingFavoriteResource">
|
||||
<div v-if="pendingFavoriteResource" class="favorite-modal__resource-meta">
|
||||
<span class="resource-id">ID: {{ pendingFavoriteResource.supplier_resource_id }}</span>
|
||||
<div class="resource-tags">
|
||||
<span class="resource-tag" v-if="pendingFavoriteResource.channel_type">
|
||||
<span v-if="pendingFavoriteResource.channel_type" class="resource-tag">
|
||||
{{ pendingFavoriteResource.channel_type }}
|
||||
</span>
|
||||
<span class="resource-tag" v-if="pendingFavoriteResource.region">
|
||||
<span v-if="pendingFavoriteResource.region" class="resource-tag">
|
||||
{{ pendingFavoriteResource.region }}
|
||||
</span>
|
||||
<span
|
||||
class="resource-tag resource-tag--weight"
|
||||
v-if="
|
||||
pendingFavoriteResource.baidu_weight !== null &&
|
||||
pendingFavoriteResource.baidu_weight !== undefined
|
||||
"
|
||||
class="resource-tag resource-tag--weight"
|
||||
>
|
||||
百度 {{ pendingFavoriteResource.baidu_weight }}
|
||||
</span>
|
||||
@@ -981,7 +987,14 @@ function toResourceRow(item: CustomerSupplierMediaResource): MediaSupplyResource
|
||||
<button type="button" class="modal-btn-cancel" @click="favoriteModalOpen = false">
|
||||
取消
|
||||
</button>
|
||||
<button type="button" class="modal-btn-ok" @click="confirmAddFavorite">确认加入</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-btn-ok"
|
||||
:disabled="favoriteMutationPending"
|
||||
@click="confirmAddFavorite"
|
||||
>
|
||||
确认加入
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
Reference in New Issue
Block a user