为资产模块新增样片管理与上传流程

扩展媒体库以支持样片类型及项目选择,为样片入口接入首页路由,并扩展 Assets 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 10:06:16 +08:00
parent ae0bc116d8
commit 403a3eefa6
13 changed files with 523 additions and 111 deletions

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
import Observation import Observation
/// 便 ViewModel /// 便 ViewModel
@MainActor @MainActor
protocol AssetsServing { protocol AssetsServing {
/// ///
@ -35,27 +35,30 @@ protocol AssetsServing {
/// ///
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse
/// ///
func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse
/// ///
func mediaAlbumDetail(id: Int) async throws -> MediaLibraryDetail func mediaAlbumDetail(id: Int, kind: MediaLibraryKind) async throws -> MediaLibraryDetail
/// ///
func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws
/// ///
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws
/// ///
func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws
/// ///
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws
/// ///
func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws
///
func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem>
/// ///
func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload<AlbumFolderItem> func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload<AlbumFolderItem>
@ -163,7 +166,7 @@ final class AssetsAPI: AssetsServing {
) )
} }
/// ///
func mediaAlbumList( func mediaAlbumList(
kind: MediaLibraryKind = .material, kind: MediaLibraryKind = .material,
keyword: String = "", keyword: String = "",
@ -185,25 +188,28 @@ final class AssetsAPI: AssetsServing {
) )
} }
/// ///
func mediaAlbumDetail(id: Int) async throws -> MediaLibraryDetail { func mediaAlbumDetail(id: Int, kind: MediaLibraryKind = .material) async throws -> MediaLibraryDetail {
try await client.send( try await client.send(
APIRequest( APIRequest(
method: .get, method: .get,
path: "/api/app/media-album/detail", path: "/api/app/media-album/detail",
queryItems: [URLQueryItem(name: "id", value: "\(id)")] queryItems: [
URLQueryItem(name: "id", value: "\(id)"),
URLQueryItem(name: "type", value: "\(kind.rawValue)")
]
) )
) )
} }
/// ///
func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws { func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws {
_ = try await client.send( _ = try await client.send(
APIRequest(method: .post, path: "/api/app/media-album/operation", body: request) APIRequest(method: .post, path: "/api/app/media-album/operation", body: request)
) as EmptyPayload ) as EmptyPayload
} }
/// ///
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws { func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws {
_ = try await client.send( _ = try await client.send(
APIRequest(method: .post, path: "/api/app/media-album/delete", body: request) APIRequest(method: .post, path: "/api/app/media-album/delete", body: request)
@ -217,7 +223,7 @@ final class AssetsAPI: AssetsServing {
) as EmptyPayload ) as EmptyPayload
} }
/// ///
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws { func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws {
_ = try await client.send( _ = try await client.send(
APIRequest(method: .post, path: "/api/app/media-album/upload", body: request) APIRequest(method: .post, path: "/api/app/media-album/upload", body: request)
@ -231,6 +237,26 @@ final class AssetsAPI: AssetsServing {
) as EmptyPayload ) as EmptyPayload
} }
///
func projectList(
scenicId: Int,
name: String? = nil,
page: Int = 1,
pageSize: Int = 50
) async throws -> ListPayload<PhotographerProjectItem> {
var query = [
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
if let name, !name.isEmpty {
query.append(URLQueryItem(name: "name", value: name))
}
return try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/project/list", queryItems: query)
)
}
/// ///
func albumFolderList( func albumFolderList(
scenicId: Int, scenicId: Int,

View File

@ -2,7 +2,7 @@
## 模块职责 ## 模块职责
Assets 模块负责首页中的相册、相册云盘素材管理入口。 Assets 模块负责首页中的相册、相册云盘素材管理和样片管理入口。
- `album_list` 进入 `AlbumListView` - `album_list` 进入 `AlbumListView`
- `album_trailer` 进入 `AlbumTrailerEntryView` - `album_trailer` 进入 `AlbumTrailerEntryView`
@ -10,8 +10,10 @@ Assets 模块负责首页中的相册、相册云盘和素材管理入口。
- `cloud_storage_transit` 进入 `CloudStorageTransitView` - `cloud_storage_transit` 进入 `CloudStorageTransitView`
- `asset_management` 进入 `MediaLibraryView(kind: .material)` - `asset_management` 进入 `MediaLibraryView(kind: .material)`
- `material_upload` 进入 `MediaLibraryUploadView` - `material_upload` 进入 `MediaLibraryUploadView`
- `sample_management` 进入 `MediaLibraryView(kind: .sample)`
- `sample_upload` 进入 `MediaLibraryUploadView(kind: .sample)`
`sample_management``sample_upload` 本轮仍保持占位,后续按样片管理独立迁移 样片复用媒体库列表、详情、上下架、删除和 OSS 上传链路;上传样片额外要求选择关联项目
## 相册管理逻辑 ## 相册管理逻辑
@ -56,32 +58,33 @@ Assets 模块负责首页中的相册、相册云盘和素材管理入口。
下载文件只保存到 App 沙盒 `Documents/CloudDownloads`,不自动写入系统相册。上传/下载记录由 `CloudTransferStore` 保存,生命周期仅限本次 App 会话,不落盘。 下载文件只保存到 App 沙盒 `Documents/CloudDownloads`,不自动写入系统相册。上传/下载记录由 `CloudTransferStore` 保存,生命周期仅限本次 App 会话,不落盘。
## 素材管理逻辑 ## 素材/样片管理逻辑
`MediaLibraryViewModel` 管理素材列表、关键词、审核状态、分页、详情、删除和上下架。 `MediaLibraryViewModel``MediaLibraryKind` 管理素材或样片列表、关键词、审核状态、分页、详情、删除和上下架。素材接口使用 `type = 1`,样片接口使用 `type = 2`
素材上下架规则: 媒体库上下架规则:
- 审核通过的素材可以上下架。 - 审核通过的素材或样片可以上下架。
- 审核未通过或待审核素材禁止上下架。 - 审核未通过或待审核素材或样片禁止上下架。
- 操作成功后刷新素材列表。 - 操作成功后刷新当前媒体库列表。
`MediaLibraryEditorViewModel` 管理上传和编辑表单。提交前会校验素材名称、当前景区、打卡点、封面、媒体文件和标签长度。 `MediaLibraryEditorViewModel` 管理上传和编辑表单。提交前会校验名称、当前景区、打卡点、封面、媒体文件和标签长度。样片上传还会加载当前景区下的项目列表,并强制选择关联项目。
素材上传流程: 素材/样片上传流程:
1. 页面通过 `PhotosPicker` 选择封面和媒体文件。 1. 页面通过 `PhotosPicker` 选择封面和媒体文件。
2. 打卡点来源于 `ScenicSpotContext.spots` 2. 打卡点来源于 `ScenicSpotContext.spots`
3. 提交时先把封面和媒体文件上传 OSS 3. 样片上传从 `projectList` 获取可关联项目,提交时携带 `project_id`
4. 再把最终 OSS URL、文件尺寸、文件大小、打卡点 ID 和标签提交给素材接口 4. 提交时先把封面和媒体文件上传 OSS
5. 上传失败时不提交素材接口。 5. 再把最终 OSS URL、文件尺寸、文件大小、打卡点 ID、标签和项目 ID 提交给媒体库接口。
6. 上传失败时不提交媒体库接口。
## 缓存边界 ## 缓存边界
相册列表、相册文件列表、云盘文件列表、素材列表、上传进度、下载记录、OSS STS、本地文件数据和素材表单都不进入 `AppSession``AccountContext` 或 TabBar 状态。 相册列表、相册文件列表、云盘文件列表、素材列表、样片列表、项目选择、上传进度、下载记录、OSS STS、本地文件数据和媒体库表单都不进入 `AppSession``AccountContext` 或 TabBar 状态。
图片缓存继续交给 Kingfisher 的 `RemoteImage`。业务模块不自行缓存远程图片文件或 `Data` 图片缓存继续交给 Kingfisher 的 `RemoteImage`。业务模块不自行缓存远程图片文件或 `Data`
## 测试要求 ## 测试要求
新增相册、云盘素材逻辑时,需要同步补充 API、ViewModel 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。 新增相册、云盘素材或样片逻辑时,需要同步补充 API、ViewModel 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。

View File

@ -301,9 +301,10 @@ struct CloudTransferItem: Identifiable, Equatable {
} }
} }
/// ///
enum MediaLibraryKind: Int, CaseIterable, Identifiable { enum MediaLibraryKind: Int, CaseIterable, Identifiable {
case material = 1 case material = 1
case sample = 2
var id: Int { rawValue } var id: Int { rawValue }
@ -312,6 +313,58 @@ enum MediaLibraryKind: Int, CaseIterable, Identifiable {
switch self { switch self {
case .material: case .material:
"素材管理" "素材管理"
case .sample:
"样片管理"
}
}
///
var uploadTitle: String {
switch self {
case .material:
"上传素材"
case .sample:
"上传样片"
}
}
///
var detailTitle: String {
switch self {
case .material:
"素材详情"
case .sample:
"样片详情"
}
}
///
var namePlaceholder: String {
switch self {
case .material:
"素材名称"
case .sample:
"样片名称"
}
}
///
var searchPlaceholder: String {
switch self {
case .material:
"搜索素材名称"
case .sample:
"搜索样片名称"
}
}
///
var emptyTitle: String {
switch self {
case .material:
"暂无素材"
case .sample:
"暂无样片"
} }
} }
} }
@ -643,21 +696,24 @@ struct MediaAlbumEditRequest: Encodable, Equatable {
} }
} }
/// ///
struct MediaAlbumOperationRequest: Encodable, Equatable { struct MediaAlbumOperationRequest: Encodable, Equatable {
let id: Int let id: Int
let type: Int
let listingStatus: Int let listingStatus: Int
/// 线 /// 线
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case type
case listingStatus = "listing_status" case listingStatus = "listing_status"
} }
} }
/// ///
struct MediaAlbumDeleteRequest: Encodable, Equatable { struct MediaAlbumDeleteRequest: Encodable, Equatable {
let id: Int let id: Int
let type: Int
} }
/// ///
@ -665,6 +721,52 @@ struct MediaAlbumAddTagRequest: Encodable, Equatable {
let name: String let name: String
} }
///
struct PhotographerProjectItem: Decodable, Identifiable, Hashable {
let id: Int
let type: Int
let typeName: String
let status: Int
let statusName: String
let name: String
let coverProject: String
let coverVideo: String
let price: String
let otPrice: String
let priceDeposit: String
/// 线
enum CodingKeys: String, CodingKey {
case id
case type
case typeName = "type_name"
case status
case statusName = "status_name"
case name
case coverProject = "cover_project"
case coverVideo = "cover_video"
case price
case otPrice = "ot_price"
case priceDeposit = "price_deposit"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
type = try container.decodeLossyInt(forKey: .type) ?? 0
typeName = try container.decodeLossyString(forKey: .typeName)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusName = try container.decodeLossyString(forKey: .statusName)
name = try container.decodeLossyString(forKey: .name)
coverProject = try container.decodeLossyString(forKey: .coverProject)
coverVideo = try container.decodeLossyString(forKey: .coverVideo)
price = try container.decodeLossyString(forKey: .price)
otPrice = try container.decodeLossyString(forKey: .otPrice)
priceDeposit = try container.decodeLossyString(forKey: .priceDeposit)
}
}
/// ///
struct MediaLocalUploadFile: Identifiable, Equatable { struct MediaLocalUploadFile: Identifiable, Equatable {
let id = UUID() let id = UUID()

View File

@ -258,7 +258,7 @@ final class CloudStorageViewModel {
} }
} }
/// ViewModel /// ViewModel
@MainActor @MainActor
@Observable @Observable
final class MediaLibraryViewModel { final class MediaLibraryViewModel {
@ -277,7 +277,7 @@ final class MediaLibraryViewModel {
private let pageSize = 10 private let pageSize = 10
/// ViewModel /// ViewModel
init(kind: MediaLibraryKind = .material) { init(kind: MediaLibraryKind = .material) {
self.kind = kind self.kind = kind
} }
@ -287,7 +287,7 @@ final class MediaLibraryViewModel {
items.count < total items.count < total
} }
/// ///
func reload(api: any AssetsServing) async { func reload(api: any AssetsServing) async {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
@ -308,7 +308,7 @@ final class MediaLibraryViewModel {
} }
} }
/// ///
func loadMore(api: any AssetsServing) async { func loadMore(api: any AssetsServing) async {
guard hasMore, !isLoadingMore else { return } guard hasMore, !isLoadingMore else { return }
isLoadingMore = true isLoadingMore = true
@ -326,20 +326,20 @@ final class MediaLibraryViewModel {
} }
} }
/// ///
func loadDetail(id: Int, api: any AssetsServing) async { func loadDetail(id: Int, api: any AssetsServing) async {
isLoadingDetail = true isLoadingDetail = true
errorMessage = nil errorMessage = nil
defer { isLoadingDetail = false } defer { isLoadingDetail = false }
do { do {
selectedDetail = try await api.mediaAlbumDetail(id: id) selectedDetail = try await api.mediaAlbumDetail(id: id, kind: kind)
} catch { } catch {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
} }
} }
/// ///
func toggleListing(_ item: MediaLibraryItem, api: any AssetsServing) async -> Bool { func toggleListing(_ item: MediaLibraryItem, api: any AssetsServing) async -> Bool {
guard item.isApproved else { guard item.isApproved else {
errorMessage = "审核通过后才能上下架" errorMessage = "审核通过后才能上下架"
@ -347,11 +347,11 @@ final class MediaLibraryViewModel {
} }
return await operate(api: api) { return await operate(api: api) {
let target = item.listing.toggled let target = item.listing.toggled
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: item.id, listingStatus: target.rawValue)) try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: item.id, type: kind.rawValue, listingStatus: target.rawValue))
} }
} }
/// ///
func toggleListing(detail: MediaLibraryDetail, api: any AssetsServing) async -> Bool { func toggleListing(detail: MediaLibraryDetail, api: any AssetsServing) async -> Bool {
guard detail.isApproved else { guard detail.isApproved else {
errorMessage = "审核通过后才能上下架" errorMessage = "审核通过后才能上下架"
@ -359,18 +359,18 @@ final class MediaLibraryViewModel {
} }
return await operate(api: api) { return await operate(api: api) {
let target = detail.listing.toggled let target = detail.listing.toggled
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: detail.id, listingStatus: target.rawValue)) try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: detail.id, type: kind.rawValue, listingStatus: target.rawValue))
} }
} }
/// ///
func delete(id: Int, api: any AssetsServing) async -> Bool { func delete(id: Int, api: any AssetsServing) async -> Bool {
await operate(api: api) { await operate(api: api) {
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: id)) try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: id, type: kind.rawValue))
} }
} }
/// ///
private func operate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool { private func operate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool {
do { do {
try await operation() try await operation()
@ -382,7 +382,7 @@ final class MediaLibraryViewModel {
} }
} }
/// ///
private func requestList(api: any AssetsServing, page: Int) async throws -> MediaLibraryListResponse { private func requestList(api: any AssetsServing, page: Int) async throws -> MediaLibraryListResponse {
try await api.mediaAlbumList( try await api.mediaAlbumList(
kind: kind, kind: kind,
@ -394,17 +394,20 @@ final class MediaLibraryViewModel {
} }
} }
/// ViewModelOSS /// ViewModelOSS
@MainActor @MainActor
@Observable @Observable
final class MediaLibraryEditorViewModel { final class MediaLibraryEditorViewModel {
var name = "" var name = ""
var description = "" var description = ""
var selectedSpotId: Int? var selectedSpotId: Int?
var selectedProjectId: Int?
var projects: [PhotographerProjectItem] = []
var tagsText = "" var tagsText = ""
var coverFile: MediaLocalUploadFile? var coverFile: MediaLocalUploadFile?
var mediaFiles: [MediaLocalUploadFile] = [] var mediaFiles: [MediaLocalUploadFile] = []
var isSubmitting = false var isSubmitting = false
var isLoadingProjects = false
var errorMessage: String? var errorMessage: String?
var didSubmitSuccessfully = false var didSubmitSuccessfully = false
@ -422,6 +425,29 @@ final class MediaLibraryEditorViewModel {
editingId != nil editingId != nil
} }
///
func loadProjects(scenicId: Int?, api: any AssetsServing) async {
guard let scenicId else {
projects = []
errorMessage = "缺少当前景区,无法加载项目"
return
}
isLoadingProjects = true
errorMessage = nil
defer { isLoadingProjects = false }
do {
let payload = try await api.projectList(scenicId: scenicId, name: nil, page: 1, pageSize: 50)
projects = payload.list
if let selectedProjectId, !projects.contains(where: { $0.id == selectedProjectId }) {
self.selectedProjectId = nil
}
} catch {
projects = []
errorMessage = error.localizedDescription
}
}
/// ///
func setCover(_ file: MediaLocalUploadFile) { func setCover(_ file: MediaLocalUploadFile) {
coverFile = file coverFile = file
@ -444,7 +470,7 @@ final class MediaLibraryEditorViewModel {
api: any AssetsServing, api: any AssetsServing,
uploadService: any OSSUploadServing uploadService: any OSSUploadServing
) async -> Bool { ) async -> Bool {
guard validate(scenicId: scenicId) else { return false } guard validate(kind: kind, scenicId: scenicId) else { return false }
guard !isSubmitting else { return false } guard !isSubmitting else { return false }
isSubmitting = true isSubmitting = true
defer { isSubmitting = false } defer { isSubmitting = false }
@ -478,7 +504,7 @@ final class MediaLibraryEditorViewModel {
scenicSpotId: selectedSpotId ?? 0, scenicSpotId: selectedSpotId ?? 0,
description: description, description: description,
materialTag: normalizedTags(), materialTag: normalizedTags(),
projectId: 0 projectId: kind == .sample ? (selectedProjectId ?? 0) : 0
) )
try await api.mediaAlbumUpload(request) try await api.mediaAlbumUpload(request)
} }
@ -490,27 +516,31 @@ final class MediaLibraryEditorViewModel {
} }
} }
/// ///
private func validate(scenicId: Int?) -> Bool { private func validate(kind: MediaLibraryKind, scenicId: Int?) -> Bool {
let title = name.trimmingCharacters(in: .whitespacesAndNewlines) let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard scenicId != nil else { guard scenicId != nil else {
errorMessage = "缺少当前景区,无法提交素材" errorMessage = "缺少当前景区,无法提交\(kind == .sample ? "样片" : "素材")"
return false return false
} }
guard !title.isEmpty else { guard !title.isEmpty else {
errorMessage = "请输入素材名称" errorMessage = "请输入\(kind == .sample ? "样片" : "素材")名称"
return false return false
} }
guard selectedSpotId != nil else { guard selectedSpotId != nil else {
errorMessage = "请选择打卡点" errorMessage = "请选择打卡点"
return false return false
} }
guard kind != .sample || selectedProjectId != nil else {
errorMessage = "请选择关联项目"
return false
}
guard coverFile != nil else { guard coverFile != nil else {
errorMessage = "请选择封面" errorMessage = "请选择封面"
return false return false
} }
guard !mediaFiles.isEmpty else { guard !mediaFiles.isEmpty else {
errorMessage = "请选择素材文件" errorMessage = "请选择\(kind == .sample ? "样片" : "素材")文件"
return false return false
} }
guard normalizedTags().count <= 120 else { guard normalizedTags().count <= 120 else {

View File

@ -8,16 +8,24 @@
import PhotosUI import PhotosUI
import SwiftUI import SwiftUI
/// ///
struct MediaLibraryView: View { struct MediaLibraryView: View {
let kind: MediaLibraryKind
@Environment(AssetsAPI.self) private var assetsAPI @Environment(AssetsAPI.self) private var assetsAPI
@Environment(RouterPath.self) private var router @Environment(RouterPath.self) private var router
@Environment(ToastCenter.self) private var toastCenter @Environment(ToastCenter.self) private var toastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = MediaLibraryViewModel(kind: .material) @State private var viewModel: MediaLibraryViewModel
@State private var selectedItem: MediaLibraryItem? @State private var selectedItem: MediaLibraryItem?
///
init(kind: MediaLibraryKind = .material) {
self.kind = kind
_viewModel = State(initialValue: MediaLibraryViewModel(kind: kind))
}
var body: some View { var body: some View {
ScrollView { ScrollView {
LazyVStack(spacing: AppMetrics.Spacing.medium) { LazyVStack(spacing: AppMetrics.Spacing.medium) {
@ -29,16 +37,16 @@ struct MediaLibraryView: View {
.padding(.vertical, AppMetrics.Spacing.medium) .padding(.vertical, AppMetrics.Spacing.medium)
} }
.background(Color(hex: 0xF5F7FA)) .background(Color(hex: 0xF5F7FA))
.navigationTitle("素材管理") .navigationTitle(kind.title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { Button {
router.navigate(to: .home(.materialUpload)) router.navigate(to: .home(kind == .sample ? .sampleUpload : .materialUpload))
} label: { } label: {
Image(systemName: "plus") Image(systemName: "plus")
} }
.accessibilityLabel("上传素材") .accessibilityLabel(kind.uploadTitle)
} }
} }
.refreshable { await reload(showLoading: false) } .refreshable { await reload(showLoading: false) }
@ -57,7 +65,7 @@ struct MediaLibraryView: View {
HStack(spacing: AppMetrics.Spacing.small) { HStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.foregroundStyle(AppDesign.placeholder) .foregroundStyle(AppDesign.placeholder)
TextField("搜索素材名称", text: $viewModel.keyword) TextField(kind.searchPlaceholder, text: $viewModel.keyword)
.submitLabel(.search) .submitLabel(.search)
.onSubmit { Task { await reload(showLoading: true) } } .onSubmit { Task { await reload(showLoading: true) } }
Button("搜索") { Button("搜索") {
@ -80,7 +88,7 @@ struct MediaLibraryView: View {
} }
} }
/// ///
@ViewBuilder @ViewBuilder
private var statsSection: some View { private var statsSection: some View {
if let info = viewModel.orderInfo { if let info = viewModel.orderInfo {
@ -96,7 +104,7 @@ struct MediaLibraryView: View {
@ViewBuilder @ViewBuilder
private var contentSection: some View { private var contentSection: some View {
if viewModel.items.isEmpty { if viewModel.items.isEmpty {
AssetsEmptyState(title: "暂无素材", message: viewModel.errorMessage ?? "当前筛选条件下没有素材") AssetsEmptyState(title: kind.emptyTitle, message: viewModel.errorMessage ?? "当前筛选条件下没有\(kind == .sample ? "样片" : "素材")")
} else { } else {
ForEach(viewModel.items) { item in ForEach(viewModel.items) { item in
MediaLibraryCard(item: item) { MediaLibraryCard(item: item) {
@ -117,7 +125,7 @@ struct MediaLibraryView: View {
} }
} }
/// ///
private func reload(showLoading: Bool) async { private func reload(showLoading: Bool) async {
await globalLoading.withOptionalLoading(showLoading) { await globalLoading.withOptionalLoading(showLoading) {
await viewModel.reload(api: assetsAPI) await viewModel.reload(api: assetsAPI)
@ -127,7 +135,7 @@ struct MediaLibraryView: View {
} }
} }
/// ///
private func toggleListing(_ item: MediaLibraryItem) async { private func toggleListing(_ item: MediaLibraryItem) async {
let success = await globalLoading.withLoading { let success = await globalLoading.withLoading {
await viewModel.toggleListing(item, api: assetsAPI) await viewModel.toggleListing(item, api: assetsAPI)
@ -136,7 +144,7 @@ struct MediaLibraryView: View {
} }
} }
/// ///
private struct MediaStatCard: View { private struct MediaStatCard: View {
let title: String let title: String
let value: String let value: String
@ -156,7 +164,7 @@ private struct MediaStatCard: View {
} }
} }
/// ///
private struct MediaLibraryCard: View { private struct MediaLibraryCard: View {
let item: MediaLibraryItem let item: MediaLibraryItem
let openAction: () -> Void let openAction: () -> Void
@ -207,7 +215,7 @@ private struct MediaLibraryCard: View {
} }
} }
/// ///
private struct MediaStatusBadge: View { private struct MediaStatusBadge: View {
let text: String let text: String
let color: Color let color: Color
@ -222,7 +230,7 @@ private struct MediaStatusBadge: View {
} }
} }
/// ///
struct MediaLibraryDetailView: View { struct MediaLibraryDetailView: View {
let item: MediaLibraryItem let item: MediaLibraryItem
let listViewModel: MediaLibraryViewModel let listViewModel: MediaLibraryViewModel
@ -245,7 +253,7 @@ struct MediaLibraryDetailView: View {
.padding(.vertical, AppMetrics.Spacing.medium) .padding(.vertical, AppMetrics.Spacing.medium)
} }
.background(Color(hex: 0xF5F7FA)) .background(Color(hex: 0xF5F7FA))
.navigationTitle("素材详情") .navigationTitle(listViewModel.kind.detailTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task { .task {
await globalLoading.withOptionalLoading(listViewModel.selectedDetail?.id != item.id) { await globalLoading.withOptionalLoading(listViewModel.selectedDetail?.id != item.id) {
@ -253,11 +261,11 @@ struct MediaLibraryDetailView: View {
} }
} }
.sheet(isPresented: $isEditing) { .sheet(isPresented: $isEditing) {
MediaLibraryUploadView(editingDetail: listViewModel.selectedDetail) MediaLibraryUploadView(kind: listViewModel.kind, editingDetail: listViewModel.selectedDetail)
} }
} }
/// ///
private var headerSection: some View { private var headerSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
RemoteImage(urlString: currentCoverURL, contentMode: .fill) { RemoteImage(urlString: currentCoverURL, contentMode: .fill) {
@ -281,18 +289,49 @@ struct MediaLibraryDetailView: View {
MediaStatusBadge(text: currentStatus.auditTitle, color: currentStatus.auditColor) MediaStatusBadge(text: currentStatus.auditTitle, color: currentStatus.auditColor)
MediaStatusBadge(text: currentListing.title, color: currentListing == .online ? AppDesign.success : AppDesign.placeholder) MediaStatusBadge(text: currentListing.title, color: currentListing == .online ? AppDesign.success : AppDesign.placeholder)
} }
if listViewModel.kind == .sample {
sampleSummaryBadges
}
detailMetaRows
} }
} }
.padding(AppMetrics.Spacing.medium) .padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
} }
/// ///
private var detailMetaRows: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
if let detail = listViewModel.selectedDetail {
MediaDetailMetaRow(title: "项目", value: detail.projectName.assetsNonEmpty ?? "--")
MediaDetailMetaRow(title: "景区", value: detail.scenicName.assetsNonEmpty ?? "--")
MediaDetailMetaRow(title: "上传人", value: detail.uploaderName.assetsNonEmpty ?? "--")
MediaDetailMetaRow(title: "创建时间", value: detail.createdAt.assetsNonEmpty ?? "--")
} else {
MediaDetailMetaRow(title: "关联", value: item.projectName.assetsNonEmpty ?? item.scenicSpotName.assetsNonEmpty ?? "--")
MediaDetailMetaRow(title: "创建时间", value: item.createdAt.assetsNonEmpty ?? "--")
}
}
}
///
private var sampleSummaryBadges: some View {
let media = listViewModel.selectedDetail?.mediaList ?? []
let videoCount = media.filter(\.isVideo).count
let imageCount = media.count - videoCount
return HStack(spacing: AppMetrics.Spacing.xSmall) {
MediaStatusBadge(text: "图片 \(imageCount)", color: AppDesign.success)
MediaStatusBadge(text: "视频 \(videoCount)", color: AppDesign.warning)
MediaStatusBadge(text: "总计 \(media.count)", color: AppDesign.primary)
}
}
///
@ViewBuilder @ViewBuilder
private var mediaSection: some View { private var mediaSection: some View {
let media = listViewModel.selectedDetail?.mediaList ?? [] let media = listViewModel.selectedDetail?.mediaList ?? []
if media.isEmpty { if media.isEmpty {
AssetsEmptyState(title: "暂无媒体文件", message: "详情接口暂未返回素材文件。") AssetsEmptyState(title: "暂无媒体文件", message: "详情接口暂未返回\(listViewModel.kind == .sample ? "样片" : "素材")文件。")
} else { } else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) {
ForEach(media) { file in ForEach(media) { file in
@ -321,12 +360,14 @@ struct MediaLibraryDetailView: View {
/// ///
private var actionSection: some View { private var actionSection: some View {
VStack(spacing: AppMetrics.Spacing.small) { VStack(spacing: AppMetrics.Spacing.small) {
if listViewModel.kind == .material {
Button("编辑素材") { Button("编辑素材") {
isEditing = true isEditing = true
} }
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight) .frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
.foregroundStyle(.white) .foregroundStyle(.white)
}
HStack(spacing: AppMetrics.Spacing.small) { HStack(spacing: AppMetrics.Spacing.small) {
Button(currentListing == .online ? "下架" : "上架") { Button(currentListing == .online ? "下架" : "上架") {
@ -350,7 +391,7 @@ struct MediaLibraryDetailView: View {
private var currentStatus: Int { listViewModel.selectedDetail?.status ?? item.status } private var currentStatus: Int { listViewModel.selectedDetail?.status ?? item.status }
private var currentListing: MediaListingStatus { listViewModel.selectedDetail?.listing ?? item.listing } private var currentListing: MediaListingStatus { listViewModel.selectedDetail?.listing ?? item.listing }
/// ///
private func toggleListing() async { private func toggleListing() async {
guard let detail = listViewModel.selectedDetail else { return } guard let detail = listViewModel.selectedDetail else { return }
let success = await globalLoading.withLoading { let success = await globalLoading.withLoading {
@ -359,7 +400,7 @@ struct MediaLibraryDetailView: View {
toastCenter.show(success ? "状态已更新" : (listViewModel.errorMessage ?? "操作失败")) toastCenter.show(success ? "状态已更新" : (listViewModel.errorMessage ?? "操作失败"))
} }
/// ///
private func delete() async { private func delete() async {
let success = await globalLoading.withLoading { let success = await globalLoading.withLoading {
await listViewModel.delete(id: item.id, api: assetsAPI) await listViewModel.delete(id: item.id, api: assetsAPI)
@ -369,8 +410,9 @@ struct MediaLibraryDetailView: View {
} }
} }
/// ///
struct MediaLibraryUploadView: View { struct MediaLibraryUploadView: View {
let kind: MediaLibraryKind
let editingDetail: MediaLibraryDetail? let editingDetail: MediaLibraryDetail?
@Environment(AccountContext.self) private var accountContext @Environment(AccountContext.self) private var accountContext
@ -385,8 +427,9 @@ struct MediaLibraryUploadView: View {
@State private var coverSelection: PhotosPickerItem? @State private var coverSelection: PhotosPickerItem?
@State private var mediaSelection: [PhotosPickerItem] = [] @State private var mediaSelection: [PhotosPickerItem] = []
/// ///
init(editingDetail: MediaLibraryDetail? = nil) { init(kind: MediaLibraryKind = .material, editingDetail: MediaLibraryDetail? = nil) {
self.kind = kind
self.editingDetail = editingDetail self.editingDetail = editingDetail
_viewModel = State(initialValue: MediaLibraryEditorViewModel(detail: editingDetail)) _viewModel = State(initialValue: MediaLibraryEditorViewModel(detail: editingDetail))
} }
@ -396,6 +439,7 @@ struct MediaLibraryUploadView: View {
VStack(spacing: AppMetrics.Spacing.medium) { VStack(spacing: AppMetrics.Spacing.medium) {
formSection formSection
spotSection spotSection
projectSection
coverSection coverSection
mediaSection mediaSection
submitButton submitButton
@ -404,8 +448,15 @@ struct MediaLibraryUploadView: View {
.padding(.vertical, AppMetrics.Spacing.medium) .padding(.vertical, AppMetrics.Spacing.medium)
} }
.background(Color(hex: 0xF5F7FA)) .background(Color(hex: 0xF5F7FA))
.navigationTitle(viewModel.isEditing ? "编辑素材" : "上传素材") .navigationTitle(viewModel.isEditing ? "编辑素材" : kind.uploadTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task {
guard kind == .sample, viewModel.projects.isEmpty else { return }
await viewModel.loadProjects(scenicId: accountContext.currentScenic?.id, api: assetsAPI)
if let message = viewModel.errorMessage {
toastCenter.show(message)
}
}
.onChange(of: coverSelection) { _, item in .onChange(of: coverSelection) { _, item in
Task { await loadCover(item) } Task { await loadCover(item) }
} }
@ -417,11 +468,13 @@ struct MediaLibraryUploadView: View {
/// ///
private var formSection: some View { private var formSection: some View {
VStack(spacing: AppMetrics.Spacing.small) { VStack(spacing: AppMetrics.Spacing.small) {
TextField("素材名称", text: $viewModel.name) TextField(kind.namePlaceholder, text: $viewModel.name)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
if kind == .material {
TextField("标签,用逗号分隔", text: $viewModel.tagsText) TextField("标签,用逗号分隔", text: $viewModel.tagsText)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
}
TextField("描述", text: $viewModel.description, axis: .vertical) TextField("描述", text: $viewModel.description, axis: .vertical)
.lineLimit(3...5) .lineLimit(3...5)
@ -429,6 +482,31 @@ struct MediaLibraryUploadView: View {
} }
} }
///
@ViewBuilder
private var projectSection: some View {
if kind == .sample {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("关联项目")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
if viewModel.projects.isEmpty {
AssetsEmptyState(title: "暂无可选项目", message: viewModel.isLoadingProjects ? "正在加载项目..." : "当前景区暂未加载到可关联项目。")
} else {
Picker("关联项目", selection: $viewModel.selectedProjectId) {
Text("请选择").tag(nil as Int?)
ForEach(viewModel.projects) { project in
Text(project.name.assetsNonEmpty ?? "项目\(project.id)").tag(Optional(project.id))
}
}
.pickerStyle(.menu)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
}
}
}
/// ///
private var spotSection: some View { private var spotSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
@ -495,7 +573,7 @@ struct MediaLibraryUploadView: View {
} }
if viewModel.mediaFiles.isEmpty { if viewModel.mediaFiles.isEmpty {
AssetsEmptyState(title: "未选择素材文件", message: "可选择图片或视频,提交前会先上传到 OSS。") AssetsEmptyState(title: "未选择\(kind == .sample ? "样片" : "素材")文件", message: "可选择图片或视频,提交前会先上传到 OSS。")
} else { } else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) {
ForEach(viewModel.mediaFiles) { file in ForEach(viewModel.mediaFiles) { file in
@ -546,20 +624,40 @@ struct MediaLibraryUploadView: View {
viewModel.addMediaFiles(files) viewModel.addMediaFiles(files)
} }
/// ///
private func submit() async { private func submit() async {
let success = await globalLoading.withLoading { let success = await globalLoading.withLoading {
await viewModel.submit( await viewModel.submit(
kind: kind,
scenicId: accountContext.currentScenic?.id, scenicId: accountContext.currentScenic?.id,
api: assetsAPI, api: assetsAPI,
uploadService: uploadService uploadService: uploadService
) )
} }
toastCenter.show(success ? "素材已提交" : (viewModel.errorMessage ?? "提交失败")) toastCenter.show(success ? "\(kind == .sample ? "样片" : "素材")已提交" : (viewModel.errorMessage ?? "提交失败"))
if success { dismiss() } if success { dismiss() }
} }
} }
///
private struct MediaDetailMetaRow: View {
let title: String
let value: String
var body: some View {
HStack(spacing: AppMetrics.Spacing.small) {
Text(title)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
Text(value)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
Spacer(minLength: 0)
}
}
}
/// ///
private struct MediaLocalPreview: View { private struct MediaLocalPreview: View {
let file: MediaLocalUploadFile let file: MediaLocalUploadFile

View File

@ -33,6 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
## 后续迁移 ## 后续迁移
目前 `payment_collection``payment_qr``payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection``permission_apply``permission_apply_status``scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management``task_management_editor``task_create` 已由 `Features/Tasks` 接管,`cloud_management``cloud_storage_transit``asset_management``material_upload``album_list``album_trailer` 已由 `Features/Assets` 接管。`sample_management``sample_upload` 后续按样片模块独立迁移`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement``scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。 目前 `payment_collection``payment_qr``payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection``permission_apply``permission_apply_status``scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management``task_management_editor``task_create` 已由 `Features/Tasks` 接管,`cloud_management``cloud_storage_transit``asset_management``material_upload``album_list``album_trailer``sample_management``sample_upload` 已由 `Features/Assets` 接管`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement``scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。 后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。

View File

@ -36,6 +36,8 @@ enum HomeRoute: Hashable {
case cloudStorageTransit case cloudStorageTransit
case materialLibrary case materialLibrary
case materialUpload case materialUpload
case sampleLibrary
case sampleUpload
case albumList case albumList
case albumTrailer case albumTrailer
case modulePlaceholder(uri: String, title: String) case modulePlaceholder(uri: String, title: String)

View File

@ -103,6 +103,10 @@ enum HomeMenuRouter {
return .destination(.materialLibrary) return .destination(.materialLibrary)
case "material_upload": case "material_upload":
return .destination(.materialUpload) return .destination(.materialUpload)
case "sample_management":
return .destination(.sampleLibrary)
case "sample_upload":
return .destination(.sampleUpload)
case "album_list": case "album_list":
return .destination(.albumList) return .destination(.albumList)
case "album_trailer": case "album_trailer":
@ -127,8 +131,6 @@ enum HomeMenuRouter {
"pm", "pm",
"pm_manager", "pm_manager",
"project_edit", "project_edit",
"sample_management",
"sample_upload",
"live_stream_management", "live_stream_management",
"live_album", "live_album",
"scenic_settlement", "scenic_settlement",

View File

@ -44,6 +44,10 @@ extension HomeRoute {
MediaLibraryView() MediaLibraryView()
case .materialUpload: case .materialUpload:
MediaLibraryUploadView() MediaLibraryUploadView()
case .sampleLibrary:
MediaLibraryView(kind: .sample)
case .sampleUpload:
MediaLibraryUploadView(kind: .sample)
case .albumList: case .albumList:
AlbumListView() AlbumListView()
case .albumTrailer: case .albumTrailer:

View File

@ -76,7 +76,7 @@ final class AssetsAPITests: XCTestCase {
let api = AssetsAPI(client: APIClient(session: session)) let api = AssetsAPI(client: APIClient(session: session))
let list = try await api.mediaAlbumList(kind: .material, keyword: "航拍", status: 1, page: 0, pageSize: 0) let list = try await api.mediaAlbumList(kind: .material, keyword: "航拍", status: 1, page: 0, pageSize: 0)
let detail = try await api.mediaAlbumDetail(id: 7) let detail = try await api.mediaAlbumDetail(id: 7, kind: .material)
XCTAssertEqual(session.requests.first?.url?.path, "/api/app/media-album/list") XCTAssertEqual(session.requests.first?.url?.path, "/api/app/media-album/list")
let query = assetsQueryItems(from: try XCTUnwrap(session.requests.first)) let query = assetsQueryItems(from: try XCTUnwrap(session.requests.first))
@ -87,6 +87,7 @@ final class AssetsAPITests: XCTestCase {
XCTAssertEqual(list.order?.totalNum, 12) XCTAssertEqual(list.order?.totalNum, 12)
XCTAssertEqual(list.list.first?.likesCount, 3) XCTAssertEqual(list.list.first?.likesCount, 3)
XCTAssertEqual(session.requests.last?.url?.path, "/api/app/media-album/detail") XCTAssertEqual(session.requests.last?.url?.path, "/api/app/media-album/detail")
XCTAssertEqual(assetsQueryItems(from: try XCTUnwrap(session.requests.last))["type"], "1")
XCTAssertEqual(detail.mediaList.first?.size, 4096) XCTAssertEqual(detail.mediaList.first?.size, 4096)
XCTAssertTrue(detail.mediaList.first?.isVideo == true) XCTAssertTrue(detail.mediaList.first?.isVideo == true)
} }
@ -108,8 +109,8 @@ final class AssetsAPITests: XCTestCase {
projectId: 0 projectId: 0
) )
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 1, listingStatus: 1)) try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 1, type: 1, listingStatus: 1))
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 1)) try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 1, type: 1))
try await api.mediaAlbumAddTag(MediaAlbumAddTagRequest(name: "航拍")) try await api.mediaAlbumAddTag(MediaAlbumAddTagRequest(name: "航拍"))
try await api.mediaAlbumUpload(upload) try await api.mediaAlbumUpload(upload)
try await api.mediaAlbumEdit(MediaAlbumEditRequest(id: 2, name: "素材", type: 1, mediaType: 2, mediaList: upload.mediaList, coverUrl: upload.coverUrl, coverSize: upload.coverSize, scenicSpotId: 6, description: "描述", materialTag: "航拍")) try await api.mediaAlbumEdit(MediaAlbumEditRequest(id: 2, name: "素材", type: 1, mediaType: 2, mediaList: upload.mediaList, coverUrl: upload.coverUrl, coverSize: upload.coverSize, scenicSpotId: 6, description: "描述", materialTag: "航拍"))
@ -126,6 +127,52 @@ final class AssetsAPITests: XCTestCase {
XCTAssertEqual((body["media_list"] as? [[String: Any]])?.first?["oss_url"] as? String, "https://cdn/a.jpg") XCTAssertEqual((body["media_list"] as? [[String: Any]])?.first?["oss_url"] as? String, "https://cdn/a.jpg")
} }
/// type=2
func testSampleRequestsAndProjectListUseExpectedTypeAndQuery() async throws {
let session = AssetsRecordingURLSession(responses: [
Self.mediaListResponse,
Self.mediaDetailResponse,
Self.emptyResponse,
Self.emptyResponse,
Self.projectListResponse,
Self.emptyResponse
])
let api = AssetsAPI(client: APIClient(session: session))
let upload = MediaAlbumUploadRequest(
name: "亲子样片",
type: 2,
mediaType: 2,
mediaList: [MediaAlbumUploadItem(originalName: "sample.jpg", ossUrl: "https://cdn/sample.jpg", size: 10, fileWidthSize: MediaAlbumFileSize(width: 100, height: 80))],
coverUrl: "https://cdn/cover.jpg",
coverSize: MediaAlbumFileSize(width: 100, height: 80),
scenicSpotId: 6,
description: "描述",
materialTag: "",
projectId: 88
)
_ = try await api.mediaAlbumList(kind: .sample, keyword: "样片", status: nil, page: 1, pageSize: 10)
_ = try await api.mediaAlbumDetail(id: 7, kind: .sample)
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 7, type: 2, listingStatus: 1))
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 7, type: 2))
let projects = try await api.projectList(scenicId: 9, name: "亲子", page: 0, pageSize: 0)
try await api.mediaAlbumUpload(upload)
XCTAssertEqual(assetsQueryItems(from: session.requests[0])["type"], "2")
XCTAssertEqual(assetsQueryItems(from: session.requests[1])["type"], "2")
XCTAssertEqual((try bodyObject(from: session.requests[2]))["type"] as? Int, 2)
XCTAssertEqual((try bodyObject(from: session.requests[3]))["type"] as? Int, 2)
XCTAssertEqual(session.requests[4].url?.path, "/api/yf-handset-app/photog/project/list")
let projectQuery = assetsQueryItems(from: session.requests[4])
XCTAssertEqual(projectQuery["scenic_id"], "9")
XCTAssertEqual(projectQuery["name"], "亲子")
XCTAssertEqual(projectQuery["page"], "1")
XCTAssertEqual(projectQuery["page_size"], "1")
XCTAssertEqual(projects.list.first?.id, 88)
XCTAssertEqual(projects.list.first?.name, "亲子旅拍")
XCTAssertEqual((try bodyObject(from: session.requests[5]))["project_id"] as? Int, 88)
}
/// 使query /// 使query
func testAlbumRequestsUseExpectedPathQueryAndBodies() async throws { func testAlbumRequestsUseExpectedPathQueryAndBodies() async throws {
let session = AssetsRecordingURLSession(responses: [ let session = AssetsRecordingURLSession(responses: [
@ -205,6 +252,11 @@ final class AssetsAPITests: XCTestCase {
{"id":"9","original_name":"v.mp4","oss_url":"https://cdn/v.mp4","thumbnail_url":"","type":"1","size":"4096","show_url":"https://cdn/v.mp4"} {"id":"9","original_name":"v.mp4","oss_url":"https://cdn/v.mp4","thumbnail_url":"","type":"1","size":"4096","show_url":"https://cdn/v.mp4"}
]}} ]}}
""".data(using: .utf8)! """.data(using: .utf8)!
fileprivate static let projectListResponse = """
{"code":100000,"msg":"success","data":{"total":"1","list":[
{"id":"88","type":"19","type_name":"多点旅拍","status":"1","status_name":"上架","name":"亲子旅拍","cover_project":"https://cdn/p.jpg","cover_video":"","price":"299","ot_price":"399","price_deposit":"99"}
]}}
""".data(using: .utf8)!
fileprivate static let albumFolderListResponse = """ fileprivate static let albumFolderListResponse = """
{"code":100000,"msg":"success","data":{"total":"1","list":[ {"code":100000,"msg":"success","data":{"total":"1","list":[
{"id":"3","name":"旅拍","cover_file_id":"11","count_video":"2","count_image":"4","cover":{"id":"11","file_name":"a.jpg","file_type":"2","file_url":"https://cdn/a.jpg","cover_url":"","file_size":"2048","file_size_human":"2KB","remark":""},"created_at":"2026","remark":"备注"} {"id":"3","name":"旅拍","cover_file_id":"11","count_video":"2","count_image":"4","cover":{"id":"11","file_name":"a.jpg","file_type":"2","file_url":"https://cdn/a.jpg","cover_url":"","file_size":"2048","file_size_human":"2KB","remark":""},"created_at":"2026","remark":"备注"}

View File

@ -95,6 +95,16 @@ final class AssetsViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.items.map(\.id), [1, 2]) XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
} }
/// 使
func testSampleMediaListUsesSampleKind() async {
let api = MockAssetsService()
let viewModel = MediaLibraryViewModel(kind: .sample)
await viewModel.reload(api: api)
XCTAssertEqual(api.mediaListRequests.first?.kind, .sample)
}
/// ///
func testRejectedMediaCannotToggleListing() async { func testRejectedMediaCannotToggleListing() async {
let api = MockAssetsService() let api = MockAssetsService()
@ -108,6 +118,18 @@ final class AssetsViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.errorMessage, "审核通过后才能上下架") XCTAssertEqual(viewModel.errorMessage, "审核通过后才能上下架")
} }
///
func testSampleToggleListingUsesSampleType() async {
let api = MockAssetsService()
let viewModel = MediaLibraryViewModel(kind: .sample)
let item = MediaLibraryItem.fixture(id: 1, status: 1, listingStatus: 0)
let success = await viewModel.toggleListing(item, api: api)
XCTAssertTrue(success)
XCTAssertEqual(api.mediaOperationRequests.first?.type, 2)
}
/// OSS URL /// OSS URL
func testMediaUploadUploadsFilesBeforeSubmit() async { func testMediaUploadUploadsFilesBeforeSubmit() async {
let api = MockAssetsService() let api = MockAssetsService()
@ -145,6 +167,61 @@ final class AssetsViewModelTests: XCTestCase {
XCTAssertEqual(api.mediaUploadRequests.count, 0) XCTAssertEqual(api.mediaUploadRequests.count, 0)
} }
///
func testSampleUploadRequiresProject() async {
let api = MockAssetsService()
let uploader = MockAssetsUploader()
let viewModel = MediaLibraryEditorViewModel()
viewModel.name = "亲子样片"
viewModel.selectedSpotId = 6
viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80))
viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)])
let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader)
XCTAssertFalse(success)
XCTAssertEqual(viewModel.errorMessage, "请选择关联项目")
XCTAssertEqual(api.mediaUploadRequests.count, 0)
}
///
func testSampleUploadUploadsFilesAndSubmitsProject() async {
let api = MockAssetsService()
let uploader = MockAssetsUploader()
let viewModel = MediaLibraryEditorViewModel()
viewModel.name = "亲子样片"
viewModel.selectedSpotId = 6
viewModel.selectedProjectId = 88
viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80))
viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)])
let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader)
XCTAssertTrue(success)
XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["cover.jpg", "a.jpg"])
XCTAssertEqual(api.mediaUploadRequests.first?.type, 2)
XCTAssertEqual(api.mediaUploadRequests.first?.projectId, 88)
}
///
func testSampleDuplicateSubmitIsIgnored() async {
let api = MockAssetsService()
let uploader = MockAssetsUploader()
let viewModel = MediaLibraryEditorViewModel()
viewModel.name = "亲子样片"
viewModel.selectedSpotId = 6
viewModel.selectedProjectId = 88
viewModel.isSubmitting = true
viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80))
viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)])
let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader)
XCTAssertFalse(success)
XCTAssertEqual(api.mediaUploadRequests.count, 0)
XCTAssertEqual(uploader.uploadedFiles.count, 0)
}
/// ///
func testAlbumListClearsWithoutScenic() async { func testAlbumListClearsWithoutScenic() async {
let api = MockAssetsService() let api = MockAssetsService()
@ -274,11 +351,19 @@ private final class MockAssetsService: AssetsServing {
} }
struct MediaListRequest: Equatable { struct MediaListRequest: Equatable {
let kind: MediaLibraryKind
let keyword: String let keyword: String
let status: Int? let status: Int?
let page: Int let page: Int
} }
struct ProjectListRequest: Equatable {
let scenicId: Int
let name: String?
let page: Int
let pageSize: Int
}
struct AlbumFolderListRequest: Equatable { struct AlbumFolderListRequest: Equatable {
let scenicId: Int let scenicId: Int
let page: Int let page: Int
@ -296,6 +381,7 @@ private final class MockAssetsService: AssetsServing {
var cloudPages: [ListPayload<CloudDriveFile>] = [ListPayload(total: 0, list: [])] var cloudPages: [ListPayload<CloudDriveFile>] = [ListPayload(total: 0, list: [])]
var mediaListResponses: [MediaLibraryListResponse] = [MediaLibraryListResponse.fixture(total: 0, ids: [])] var mediaListResponses: [MediaLibraryListResponse] = [MediaLibraryListResponse.fixture(total: 0, ids: [])]
var projectPages: [ListPayload<PhotographerProjectItem>] = [ListPayload(total: 0, list: [])]
var albumFolderPages: [ListPayload<AlbumFolderItem>] = [ListPayload(total: 0, list: [])] var albumFolderPages: [ListPayload<AlbumFolderItem>] = [ListPayload(total: 0, list: [])]
var albumFilePages: [ListPayload<AlbumFileItem>] = [ListPayload(total: 0, list: [])] var albumFilePages: [ListPayload<AlbumFileItem>] = [ListPayload(total: 0, list: [])]
var uploadPermission = true var uploadPermission = true
@ -306,6 +392,7 @@ private final class MockAssetsService: AssetsServing {
var mediaOperationRequests: [MediaAlbumOperationRequest] = [] var mediaOperationRequests: [MediaAlbumOperationRequest] = []
var mediaUploadRequests: [MediaAlbumUploadRequest] = [] var mediaUploadRequests: [MediaAlbumUploadRequest] = []
var mediaEditRequests: [MediaAlbumEditRequest] = [] var mediaEditRequests: [MediaAlbumEditRequest] = []
var projectListRequests: [ProjectListRequest] = []
var albumFolderListRequests: [AlbumFolderListRequest] = [] var albumFolderListRequests: [AlbumFolderListRequest] = []
var albumInfoRequests: [Int] = [] var albumInfoRequests: [Int] = []
var albumFileListRequests: [(scenicId: Int, folderId: Int, fileType: Int, page: Int)] = [] var albumFileListRequests: [(scenicId: Int, folderId: Int, fileType: Int, page: Int)] = []
@ -347,12 +434,12 @@ private final class MockAssetsService: AssetsServing {
/// ///
func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse { func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse {
mediaListRequests.append(MediaListRequest(keyword: keyword, status: status, page: page)) mediaListRequests.append(MediaListRequest(kind: kind, keyword: keyword, status: status, page: page))
return mediaListResponses.isEmpty ? MediaLibraryListResponse.fixture(total: 0, ids: []) : mediaListResponses.removeFirst() return mediaListResponses.isEmpty ? MediaLibraryListResponse.fixture(total: 0, ids: []) : mediaListResponses.removeFirst()
} }
/// ///
func mediaAlbumDetail(id: Int) async throws -> MediaLibraryDetail { func mediaAlbumDetail(id: Int, kind: MediaLibraryKind) async throws -> MediaLibraryDetail {
MediaLibraryDetail.fixture(id: id) MediaLibraryDetail.fixture(id: id)
} }
@ -377,6 +464,12 @@ private final class MockAssetsService: AssetsServing {
mediaEditRequests.append(request) mediaEditRequests.append(request)
} }
///
func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem> {
projectListRequests.append(ProjectListRequest(scenicId: scenicId, name: name, page: page, pageSize: pageSize))
return projectPages.isEmpty ? ListPayload(total: 0, list: []) : projectPages.removeFirst()
}
/// ///
func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload<AlbumFolderItem> { func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload<AlbumFolderItem> {
albumFolderListRequests.append(AlbumFolderListRequest(scenicId: scenicId, page: page, pageSize: pageSize, name: name, startTime: startTime, endTime: endTime)) albumFolderListRequests.append(AlbumFolderListRequest(scenicId: scenicId, page: page, pageSize: pageSize, name: name, startTime: startTime, endTime: endTime))

View File

@ -41,6 +41,8 @@ final class HomeMenuRouterTests: XCTestCase {
XCTAssertEqual(HomeMenuRouter.resolve(uri: "material_upload", title: ""), .destination(.materialUpload)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "material_upload", title: ""), .destination(.materialUpload))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_trailer", title: ""), .destination(.albumTrailer)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_trailer", title: ""), .destination(.albumTrailer))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload))
} }
/// ///
@ -54,8 +56,8 @@ final class HomeMenuRouterTests: XCTestCase {
.destination(.modulePlaceholder(uri: "scenic_settlement_review", title: "结算审核")) .destination(.modulePlaceholder(uri: "scenic_settlement_review", title: "结算审核"))
) )
XCTAssertEqual( XCTAssertEqual(
HomeMenuRouter.resolve(uri: "sample_upload", title: ""), HomeMenuRouter.resolve(uri: "message_center", title: ""),
.destination(.modulePlaceholder(uri: "sample_upload", title: "上传样片")) .destination(.modulePlaceholder(uri: "message_center", title: "消息中心"))
) )
} }
@ -167,10 +169,8 @@ final class HomeMenuRouterTests: XCTestCase {
XCTAssertTrue(uris.contains("wallet")) XCTAssertTrue(uris.contains("wallet"))
XCTAssertTrue(uris.contains("sample_management")) XCTAssertTrue(uris.contains("sample_management"))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList))
XCTAssertEqual( XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary))
HomeMenuRouter.resolve(uri: "sample_management", title: ""), XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload))
.destination(.modulePlaceholder(uri: "sample_management", title: "样片管理"))
)
} }
#endif #endif

View File

@ -18,7 +18,7 @@
| 完成 | 全局状态拆分 | `AppSession``AccountContext``PermissionContext``ScenicSpotContext``AppRouter``ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 | | 完成 | 全局状态拆分 | `AppSession``AccountContext``PermissionContext``ScenicSpotContext``AppRouter``ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 |
| 完成 | 登录态缓存 | token 使用 Keychain账号快照、上次手机号、偏好使用 UserDefaults冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 | | 完成 | 登录态缓存 | token 使用 Keychain账号快照、上次手机号、偏好使用 UserDefaults冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 |
| 完成 | 网络框架 | `APIClient``APIRequest``APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 | | 完成 | 网络框架 | `APIClient``APIRequest``APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 |
| 完成 | OSS 上传 | `UploadAPI``OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材文件上传处理。 | 后续样片等模块迁移时继续复用。 | | 完成 | OSS 上传 | `UploadAPI``OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材/样片文件上传处理。 | 后续新上传场景继续复用。 |
| 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 | | 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 |
| 完成 | 设计常量 | `AppDesign``AppMetrics` 统一颜色、字号、间距等通用尺寸。 | 继续替换新页面中的零散常量。 | | 完成 | 设计常量 | `AppDesign``AppMetrics` 统一颜色、字号、间距等通用尺寸。 | 继续替换新页面中的零散常量。 |
| 完成 | 文档规范 | 已有 `AGENTS.md` 约束;现有模块均有模块说明文档。 | 后续每个新功能模块同步新增模块 md。 | | 完成 | 文档规范 | 已有 `AGENTS.md` 约束;现有模块均有模块说明文档。 | 后续每个新功能模块同步新增模块 md。 |
@ -96,8 +96,8 @@
| 完成 | 相册云盘 | 云盘文件/文件夹浏览、搜索筛选、排序、分页、上传、预览、下载、重命名、移动、删除、传输记录。 | 云盘下载只保存到 App 沙盒 `Documents/CloudDownloads`,不写入系统相册。 | | 完成 | 相册云盘 | 云盘文件/文件夹浏览、搜索筛选、排序、分页、上传、预览、下载、重命名、移动、删除、传输记录。 | 云盘下载只保存到 App 沙盒 `Documents/CloudDownloads`,不写入系统相册。 |
| 完成 | 相册管理 | 相册列表、搜索筛选、日期筛选、创建相册、相册详情、图片/视频列表、预览、设置封面、删除文件、编辑名称/备注。 | 后续如旧工程补充更多相册运营动作再继续迁移。 | | 完成 | 相册管理 | 相册列表、搜索筛选、日期筛选、创建相册、相册详情、图片/视频列表、预览、设置封面、删除文件、编辑名称/备注。 | 后续如旧工程补充更多相册运营动作再继续迁移。 |
| 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 | | 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 |
| 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 样片管理仍按独立模块后续迁移。 | | 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 。 |
| 部分完成 | 样片管理 | 入口已识别OSS 上传服务已具备。 | 迁移样片列表、上传样片。 | | 完成 | 样片管理 | 样片列表、关键词/审核状态筛选、分页、详情、上下架、删除、上传样片、关联项目选择、样片文件 OSS 上传。 | 完整项目管理、样片编辑、视频播放器增强后续单独迁移。 |
| 部分完成 | 直播管理 | 入口已识别,进入占位页。 | 迁移直播管理和直播相册。 | | 部分完成 | 直播管理 | 入口已识别,进入占位页。 | 迁移直播管理和直播相册。 |
| 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 | | 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 |
| 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 | | 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 |
@ -112,7 +112,7 @@
| 状态 | 模块 | 说明 | | 状态 | 模块 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| 部分完成 | Assets 模块 | 已迁移相册管理、相册预览上传、相册云盘素材管理样片管理上传样片仍未迁移。 | | 完成 | Assets 模块 | 已迁移相册管理、相册预览上传、相册云盘素材管理样片管理上传样片。 |
| 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 | | 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 |
| 未开始 | Core/Push | 旧工程推送能力尚未迁移。 | | 未开始 | Core/Push | 旧工程推送能力尚未迁移。 |
| 未开始 | Core/Queue | 旧工程队列基础能力尚未迁移。 | | 未开始 | Core/Queue | 旧工程队列基础能力尚未迁移。 |
@ -131,6 +131,6 @@ xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination
## 下一批建议迁移顺序 ## 下一批建议迁移顺序
1. 打卡点管理/位置上报:依赖当前景区和景点上下文,当前上下文已经稳定。 1. 打卡点管理/位置上报:依赖当前景区和景点上下文,当前上下文已经稳定。
2. 样片管理/上传样片:与素材管理和相册上传相近,可复用素材列表、上传和标签处理经验 2. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾
3. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾 3. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试
4. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试 4. 项目管理:样片上传已具备轻量项目选择,后续可迁移完整项目列表、详情和编辑