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:
2026-07-10 20:10:50 +08:00
parent 0bf4b73ebc
commit 67a6cbcd9e
15 changed files with 2378 additions and 518 deletions
+41 -6
View File
@@ -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()
}
+12 -179
View File
@@ -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>