为资产模块新增样片管理与上传流程
扩展媒体库以支持样片类型及项目选择,为样片入口接入首页路由,并扩展 Assets 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -8,7 +8,7 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 资产服务协议,抽象云盘和素材管理接口以便 ViewModel 测试替换。
|
||||
/// 资产服务协议,抽象云盘、媒体库和相册接口以便 ViewModel 测试替换。
|
||||
@MainActor
|
||||
protocol AssetsServing {
|
||||
/// 获取云盘文件列表。
|
||||
@ -35,27 +35,30 @@ protocol AssetsServing {
|
||||
/// 检查当前账号是否允许上传云盘文件。
|
||||
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse
|
||||
|
||||
/// 获取素材列表。
|
||||
/// 获取素材或样片列表。
|
||||
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 mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws
|
||||
|
||||
/// 新增素材标签。
|
||||
func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws
|
||||
|
||||
/// 上传素材。
|
||||
/// 上传素材或样片。
|
||||
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) 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>
|
||||
|
||||
@ -163,7 +166,7 @@ final class AssetsAPI: AssetsServing {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取素材列表。
|
||||
/// 获取素材或样片列表。
|
||||
func mediaAlbumList(
|
||||
kind: MediaLibraryKind = .material,
|
||||
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(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
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 {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/media-album/operation", body: request)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 删除素材。
|
||||
/// 删除素材或样片。
|
||||
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/media-album/delete", body: request)
|
||||
@ -217,7 +223,7 @@ final class AssetsAPI: AssetsServing {
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 上传素材。
|
||||
/// 上传素材或样片。
|
||||
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/media-album/upload", body: request)
|
||||
@ -231,6 +237,26 @@ final class AssetsAPI: AssetsServing {
|
||||
) 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(
|
||||
scenicId: Int,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 模块职责
|
||||
|
||||
Assets 模块负责首页中的相册、相册云盘和素材管理入口。
|
||||
Assets 模块负责首页中的相册、相册云盘、素材管理和样片管理入口。
|
||||
|
||||
- `album_list` 进入 `AlbumListView`。
|
||||
- `album_trailer` 进入 `AlbumTrailerEntryView`。
|
||||
@ -10,8 +10,10 @@ Assets 模块负责首页中的相册、相册云盘和素材管理入口。
|
||||
- `cloud_storage_transit` 进入 `CloudStorageTransitView`。
|
||||
- `asset_management` 进入 `MediaLibraryView(kind: .material)`。
|
||||
- `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 会话,不落盘。
|
||||
|
||||
## 素材管理逻辑
|
||||
## 素材/样片管理逻辑
|
||||
|
||||
`MediaLibraryViewModel` 管理素材列表、关键词、审核状态、分页、详情、删除和上下架。
|
||||
`MediaLibraryViewModel` 按 `MediaLibraryKind` 管理素材或样片列表、关键词、审核状态、分页、详情、删除和上下架。素材接口使用 `type = 1`,样片接口使用 `type = 2`。
|
||||
|
||||
素材上下架规则:
|
||||
媒体库上下架规则:
|
||||
|
||||
- 审核通过的素材可以上下架。
|
||||
- 审核未通过或待审核素材禁止上下架。
|
||||
- 操作成功后刷新素材列表。
|
||||
- 审核通过的素材或样片可以上下架。
|
||||
- 审核未通过或待审核的素材或样片禁止上下架。
|
||||
- 操作成功后刷新当前媒体库列表。
|
||||
|
||||
`MediaLibraryEditorViewModel` 管理上传和编辑表单。提交前会校验素材名称、当前景区、打卡点、封面、媒体文件和标签长度。
|
||||
`MediaLibraryEditorViewModel` 管理上传和编辑表单。提交前会校验名称、当前景区、打卡点、封面、媒体文件和标签长度。样片上传还会加载当前景区下的项目列表,并强制选择关联项目。
|
||||
|
||||
素材上传流程:
|
||||
素材/样片上传流程:
|
||||
|
||||
1. 页面通过 `PhotosPicker` 选择封面和媒体文件。
|
||||
2. 打卡点来源于 `ScenicSpotContext.spots`。
|
||||
3. 提交时先把封面和媒体文件上传 OSS。
|
||||
4. 再把最终 OSS URL、文件尺寸、文件大小、打卡点 ID 和标签提交给素材接口。
|
||||
5. 上传失败时不提交素材接口。
|
||||
3. 样片上传从 `projectList` 获取可关联项目,提交时携带 `project_id`。
|
||||
4. 提交时先把封面和媒体文件上传 OSS。
|
||||
5. 再把最终 OSS URL、文件尺寸、文件大小、打卡点 ID、标签和项目 ID 提交给媒体库接口。
|
||||
6. 上传失败时不提交媒体库接口。
|
||||
|
||||
## 缓存边界
|
||||
|
||||
相册列表、相册文件列表、云盘文件列表、素材列表、上传进度、下载记录、OSS STS、本地文件数据和素材表单都不进入 `AppSession`、`AccountContext` 或 TabBar 状态。
|
||||
相册列表、相册文件列表、云盘文件列表、素材列表、样片列表、项目选择、上传进度、下载记录、OSS STS、本地文件数据和媒体库表单都不进入 `AppSession`、`AccountContext` 或 TabBar 状态。
|
||||
|
||||
图片缓存继续交给 Kingfisher 的 `RemoteImage`。业务模块不自行缓存远程图片文件或 `Data`。
|
||||
|
||||
## 测试要求
|
||||
|
||||
新增相册、云盘或素材逻辑时,需要同步补充 API、ViewModel 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。
|
||||
新增相册、云盘、素材或样片逻辑时,需要同步补充 API、ViewModel 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。
|
||||
|
||||
@ -301,9 +301,10 @@ struct CloudTransferItem: Identifiable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材库类型实体,当前只接管素材管理,样片管理后续单独迁移。
|
||||
/// 素材库类型实体,表示素材或样片两类媒体资产。
|
||||
enum MediaLibraryKind: Int, CaseIterable, Identifiable {
|
||||
case material = 1
|
||||
case sample = 2
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
@ -312,6 +313,58 @@ enum MediaLibraryKind: Int, CaseIterable, Identifiable {
|
||||
switch self {
|
||||
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 {
|
||||
let id: Int
|
||||
let type: Int
|
||||
let listingStatus: Int
|
||||
|
||||
/// 请求字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case type
|
||||
case listingStatus = "listing_status"
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材删除请求实体。
|
||||
/// 素材删除请求实体,用于删除素材或样片。
|
||||
struct MediaAlbumDeleteRequest: Encodable, Equatable {
|
||||
let id: Int
|
||||
let type: Int
|
||||
}
|
||||
|
||||
/// 素材标签新增请求实体。
|
||||
@ -665,6 +721,52 @@ struct MediaAlbumAddTagRequest: Encodable, Equatable {
|
||||
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 {
|
||||
let id = UUID()
|
||||
|
||||
@ -258,7 +258,7 @@ final class CloudStorageViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材库 ViewModel,负责素材列表、筛选、分页、详情和上下架。
|
||||
/// 媒体库 ViewModel,负责素材或样片列表、筛选、分页、详情和上下架。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MediaLibraryViewModel {
|
||||
@ -277,7 +277,7 @@ final class MediaLibraryViewModel {
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
/// 初始化素材库 ViewModel。
|
||||
/// 初始化媒体库 ViewModel。
|
||||
init(kind: MediaLibraryKind = .material) {
|
||||
self.kind = kind
|
||||
}
|
||||
@ -287,7 +287,7 @@ final class MediaLibraryViewModel {
|
||||
items.count < total
|
||||
}
|
||||
|
||||
/// 重新加载素材列表第一页。
|
||||
/// 重新加载媒体库列表第一页。
|
||||
func reload(api: any AssetsServing) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
@ -308,7 +308,7 @@ final class MediaLibraryViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载素材列表下一页。
|
||||
/// 加载媒体库列表下一页。
|
||||
func loadMore(api: any AssetsServing) async {
|
||||
guard hasMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
@ -326,20 +326,20 @@ final class MediaLibraryViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载素材详情,失败时保留已有详情或摘要。
|
||||
/// 加载媒体库详情,失败时保留已有详情或摘要。
|
||||
func loadDetail(id: Int, api: any AssetsServing) async {
|
||||
isLoadingDetail = true
|
||||
errorMessage = nil
|
||||
defer { isLoadingDetail = false }
|
||||
|
||||
do {
|
||||
selectedDetail = try await api.mediaAlbumDetail(id: id)
|
||||
selectedDetail = try await api.mediaAlbumDetail(id: id, kind: kind)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新素材上下架状态,审核未通过时禁止操作。
|
||||
/// 更新素材或样片上下架状态,审核未通过时禁止操作。
|
||||
func toggleListing(_ item: MediaLibraryItem, api: any AssetsServing) async -> Bool {
|
||||
guard item.isApproved else {
|
||||
errorMessage = "审核通过后才能上下架"
|
||||
@ -347,11 +347,11 @@ final class MediaLibraryViewModel {
|
||||
}
|
||||
return await operate(api: api) {
|
||||
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 {
|
||||
guard detail.isApproved else {
|
||||
errorMessage = "审核通过后才能上下架"
|
||||
@ -359,18 +359,18 @@ final class MediaLibraryViewModel {
|
||||
}
|
||||
return await operate(api: api) {
|
||||
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 {
|
||||
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 {
|
||||
do {
|
||||
try await operation()
|
||||
@ -382,7 +382,7 @@ final class MediaLibraryViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装并发送素材列表请求。
|
||||
/// 组装并发送媒体库列表请求。
|
||||
private func requestList(api: any AssetsServing, page: Int) async throws -> MediaLibraryListResponse {
|
||||
try await api.mediaAlbumList(
|
||||
kind: kind,
|
||||
@ -394,17 +394,20 @@ final class MediaLibraryViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材上传编辑 ViewModel,负责表单校验、OSS 上传和提交素材。
|
||||
/// 媒体库上传编辑 ViewModel,负责表单校验、OSS 上传和提交素材或样片。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MediaLibraryEditorViewModel {
|
||||
var name = ""
|
||||
var description = ""
|
||||
var selectedSpotId: Int?
|
||||
var selectedProjectId: Int?
|
||||
var projects: [PhotographerProjectItem] = []
|
||||
var tagsText = ""
|
||||
var coverFile: MediaLocalUploadFile?
|
||||
var mediaFiles: [MediaLocalUploadFile] = []
|
||||
var isSubmitting = false
|
||||
var isLoadingProjects = false
|
||||
var errorMessage: String?
|
||||
var didSubmitSuccessfully = false
|
||||
|
||||
@ -422,6 +425,29 @@ final class MediaLibraryEditorViewModel {
|
||||
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) {
|
||||
coverFile = file
|
||||
@ -444,7 +470,7 @@ final class MediaLibraryEditorViewModel {
|
||||
api: any AssetsServing,
|
||||
uploadService: any OSSUploadServing
|
||||
) async -> Bool {
|
||||
guard validate(scenicId: scenicId) else { return false }
|
||||
guard validate(kind: kind, scenicId: scenicId) else { return false }
|
||||
guard !isSubmitting else { return false }
|
||||
isSubmitting = true
|
||||
defer { isSubmitting = false }
|
||||
@ -478,7 +504,7 @@ final class MediaLibraryEditorViewModel {
|
||||
scenicSpotId: selectedSpotId ?? 0,
|
||||
description: description,
|
||||
materialTag: normalizedTags(),
|
||||
projectId: 0
|
||||
projectId: kind == .sample ? (selectedProjectId ?? 0) : 0
|
||||
)
|
||||
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)
|
||||
guard scenicId != nil else {
|
||||
errorMessage = "缺少当前景区,无法提交素材"
|
||||
errorMessage = "缺少当前景区,无法提交\(kind == .sample ? "样片" : "素材")"
|
||||
return false
|
||||
}
|
||||
guard !title.isEmpty else {
|
||||
errorMessage = "请输入素材名称"
|
||||
errorMessage = "请输入\(kind == .sample ? "样片" : "素材")名称"
|
||||
return false
|
||||
}
|
||||
guard selectedSpotId != nil else {
|
||||
errorMessage = "请选择打卡点"
|
||||
return false
|
||||
}
|
||||
guard kind != .sample || selectedProjectId != nil else {
|
||||
errorMessage = "请选择关联项目"
|
||||
return false
|
||||
}
|
||||
guard coverFile != nil else {
|
||||
errorMessage = "请选择封面"
|
||||
return false
|
||||
}
|
||||
guard !mediaFiles.isEmpty else {
|
||||
errorMessage = "请选择素材文件"
|
||||
errorMessage = "请选择\(kind == .sample ? "样片" : "素材")文件"
|
||||
return false
|
||||
}
|
||||
guard normalizedTags().count <= 120 else {
|
||||
|
||||
@ -8,16 +8,24 @@
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
/// 素材管理页面,展示素材列表、筛选、统计和上下架入口。
|
||||
/// 媒体库页面,展示素材或样片列表、筛选、统计和上下架入口。
|
||||
struct MediaLibraryView: View {
|
||||
let kind: MediaLibraryKind
|
||||
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = MediaLibraryViewModel(kind: .material)
|
||||
@State private var viewModel: MediaLibraryViewModel
|
||||
@State private var selectedItem: MediaLibraryItem?
|
||||
|
||||
/// 初始化媒体库页面。
|
||||
init(kind: MediaLibraryKind = .material) {
|
||||
self.kind = kind
|
||||
_viewModel = State(initialValue: MediaLibraryViewModel(kind: kind))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||
@ -29,16 +37,16 @@ struct MediaLibraryView: View {
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("素材管理")
|
||||
.navigationTitle(kind.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
router.navigate(to: .home(.materialUpload))
|
||||
router.navigate(to: .home(kind == .sample ? .sampleUpload : .materialUpload))
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("上传素材")
|
||||
.accessibilityLabel(kind.uploadTitle)
|
||||
}
|
||||
}
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
@ -57,7 +65,7 @@ struct MediaLibraryView: View {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
TextField("搜索素材名称", text: $viewModel.keyword)
|
||||
TextField(kind.searchPlaceholder, text: $viewModel.keyword)
|
||||
.submitLabel(.search)
|
||||
.onSubmit { Task { await reload(showLoading: true) } }
|
||||
Button("搜索") {
|
||||
@ -80,7 +88,7 @@ struct MediaLibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计区域,展示素材订单概览。
|
||||
/// 统计区域,展示媒体库订单概览。
|
||||
@ViewBuilder
|
||||
private var statsSection: some View {
|
||||
if let info = viewModel.orderInfo {
|
||||
@ -96,7 +104,7 @@ struct MediaLibraryView: View {
|
||||
@ViewBuilder
|
||||
private var contentSection: some View {
|
||||
if viewModel.items.isEmpty {
|
||||
AssetsEmptyState(title: "暂无素材", message: viewModel.errorMessage ?? "当前筛选条件下没有素材。")
|
||||
AssetsEmptyState(title: kind.emptyTitle, message: viewModel.errorMessage ?? "当前筛选条件下没有\(kind == .sample ? "样片" : "素材")。")
|
||||
} else {
|
||||
ForEach(viewModel.items) { item in
|
||||
MediaLibraryCard(item: item) {
|
||||
@ -117,7 +125,7 @@ struct MediaLibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 重新加载素材列表。
|
||||
/// 重新加载媒体库列表。
|
||||
private func reload(showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading) {
|
||||
await viewModel.reload(api: assetsAPI)
|
||||
@ -127,7 +135,7 @@ struct MediaLibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换素材上下架状态。
|
||||
/// 切换媒体库上下架状态。
|
||||
private func toggleListing(_ item: MediaLibraryItem) async {
|
||||
let success = await globalLoading.withLoading {
|
||||
await viewModel.toggleListing(item, api: assetsAPI)
|
||||
@ -136,7 +144,7 @@ struct MediaLibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材统计卡片。
|
||||
/// 媒体库统计卡片。
|
||||
private struct MediaStatCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
@ -156,7 +164,7 @@ private struct MediaStatCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材列表卡片。
|
||||
/// 媒体库列表卡片。
|
||||
private struct MediaLibraryCard: View {
|
||||
let item: MediaLibraryItem
|
||||
let openAction: () -> Void
|
||||
@ -207,7 +215,7 @@ private struct MediaLibraryCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材状态标签。
|
||||
/// 媒体库状态标签。
|
||||
private struct MediaStatusBadge: View {
|
||||
let text: String
|
||||
let color: Color
|
||||
@ -222,7 +230,7 @@ private struct MediaStatusBadge: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材详情页面,展示基础信息、统计、媒体文件和操作按钮。
|
||||
/// 媒体库详情页面,展示基础信息、统计、媒体文件和操作按钮。
|
||||
struct MediaLibraryDetailView: View {
|
||||
let item: MediaLibraryItem
|
||||
let listViewModel: MediaLibraryViewModel
|
||||
@ -245,7 +253,7 @@ struct MediaLibraryDetailView: View {
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("素材详情")
|
||||
.navigationTitle(listViewModel.kind.detailTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await globalLoading.withOptionalLoading(listViewModel.selectedDetail?.id != item.id) {
|
||||
@ -253,11 +261,11 @@ struct MediaLibraryDetailView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isEditing) {
|
||||
MediaLibraryUploadView(editingDetail: listViewModel.selectedDetail)
|
||||
MediaLibraryUploadView(kind: listViewModel.kind, editingDetail: listViewModel.selectedDetail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材详情头部信息。
|
||||
/// 媒体库详情头部信息。
|
||||
private var headerSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||||
RemoteImage(urlString: currentCoverURL, contentMode: .fill) {
|
||||
@ -281,18 +289,49 @@ struct MediaLibraryDetailView: View {
|
||||
MediaStatusBadge(text: currentStatus.auditTitle, color: currentStatus.auditColor)
|
||||
MediaStatusBadge(text: currentListing.title, color: currentListing == .online ? AppDesign.success : AppDesign.placeholder)
|
||||
}
|
||||
if listViewModel.kind == .sample {
|
||||
sampleSummaryBadges
|
||||
}
|
||||
detailMetaRows
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.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
|
||||
private var mediaSection: some View {
|
||||
let media = listViewModel.selectedDetail?.mediaList ?? []
|
||||
if media.isEmpty {
|
||||
AssetsEmptyState(title: "暂无媒体文件", message: "详情接口暂未返回素材文件。")
|
||||
AssetsEmptyState(title: "暂无媒体文件", message: "详情接口暂未返回\(listViewModel.kind == .sample ? "样片" : "素材")文件。")
|
||||
} else {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(media) { file in
|
||||
@ -321,12 +360,14 @@ struct MediaLibraryDetailView: View {
|
||||
/// 操作按钮区域。
|
||||
private var actionSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
if listViewModel.kind == .material {
|
||||
Button("编辑素材") {
|
||||
isEditing = true
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Button(currentListing == .online ? "下架" : "上架") {
|
||||
@ -350,7 +391,7 @@ struct MediaLibraryDetailView: View {
|
||||
private var currentStatus: Int { listViewModel.selectedDetail?.status ?? item.status }
|
||||
private var currentListing: MediaListingStatus { listViewModel.selectedDetail?.listing ?? item.listing }
|
||||
|
||||
/// 切换详情素材上下架状态。
|
||||
/// 切换详情媒体库上下架状态。
|
||||
private func toggleListing() async {
|
||||
guard let detail = listViewModel.selectedDetail else { return }
|
||||
let success = await globalLoading.withLoading {
|
||||
@ -359,7 +400,7 @@ struct MediaLibraryDetailView: View {
|
||||
toastCenter.show(success ? "状态已更新" : (listViewModel.errorMessage ?? "操作失败"))
|
||||
}
|
||||
|
||||
/// 删除素材并返回列表。
|
||||
/// 删除媒体库项目并返回列表。
|
||||
private func delete() async {
|
||||
let success = await globalLoading.withLoading {
|
||||
await listViewModel.delete(id: item.id, api: assetsAPI)
|
||||
@ -369,8 +410,9 @@ struct MediaLibraryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材上传或编辑页面,负责选择封面、媒体文件和打卡点后提交。
|
||||
/// 媒体库上传或编辑页面,负责选择封面、媒体文件、打卡点和样片项目后提交。
|
||||
struct MediaLibraryUploadView: View {
|
||||
let kind: MediaLibraryKind
|
||||
let editingDetail: MediaLibraryDetail?
|
||||
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@ -385,8 +427,9 @@ struct MediaLibraryUploadView: View {
|
||||
@State private var coverSelection: PhotosPickerItem?
|
||||
@State private var mediaSelection: [PhotosPickerItem] = []
|
||||
|
||||
/// 初始化素材上传或编辑页面。
|
||||
init(editingDetail: MediaLibraryDetail? = nil) {
|
||||
/// 初始化媒体库上传或编辑页面。
|
||||
init(kind: MediaLibraryKind = .material, editingDetail: MediaLibraryDetail? = nil) {
|
||||
self.kind = kind
|
||||
self.editingDetail = editingDetail
|
||||
_viewModel = State(initialValue: MediaLibraryEditorViewModel(detail: editingDetail))
|
||||
}
|
||||
@ -396,6 +439,7 @@ struct MediaLibraryUploadView: View {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
formSection
|
||||
spotSection
|
||||
projectSection
|
||||
coverSection
|
||||
mediaSection
|
||||
submitButton
|
||||
@ -404,8 +448,15 @@ struct MediaLibraryUploadView: View {
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle(viewModel.isEditing ? "编辑素材" : "上传素材")
|
||||
.navigationTitle(viewModel.isEditing ? "编辑素材" : kind.uploadTitle)
|
||||
.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
|
||||
Task { await loadCover(item) }
|
||||
}
|
||||
@ -417,11 +468,13 @@ struct MediaLibraryUploadView: View {
|
||||
/// 表单基础信息区域。
|
||||
private var formSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
TextField("素材名称", text: $viewModel.name)
|
||||
TextField(kind.namePlaceholder, text: $viewModel.name)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
|
||||
if kind == .material {
|
||||
TextField("标签,用逗号分隔", text: $viewModel.tagsText)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
}
|
||||
|
||||
TextField("描述", text: $viewModel.description, axis: .vertical)
|
||||
.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 {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
@ -495,7 +573,7 @@ struct MediaLibraryUploadView: View {
|
||||
}
|
||||
|
||||
if viewModel.mediaFiles.isEmpty {
|
||||
AssetsEmptyState(title: "未选择素材文件", message: "可选择图片或视频,提交前会先上传到 OSS。")
|
||||
AssetsEmptyState(title: "未选择\(kind == .sample ? "样片" : "素材")文件", message: "可选择图片或视频,提交前会先上传到 OSS。")
|
||||
} else {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(viewModel.mediaFiles) { file in
|
||||
@ -546,20 +624,40 @@ struct MediaLibraryUploadView: View {
|
||||
viewModel.addMediaFiles(files)
|
||||
}
|
||||
|
||||
/// 提交素材表单。
|
||||
/// 提交媒体库表单。
|
||||
private func submit() async {
|
||||
let success = await globalLoading.withLoading {
|
||||
await viewModel.submit(
|
||||
kind: kind,
|
||||
scenicId: accountContext.currentScenic?.id,
|
||||
api: assetsAPI,
|
||||
uploadService: uploadService
|
||||
)
|
||||
}
|
||||
toastCenter.show(success ? "素材已提交" : (viewModel.errorMessage ?? "提交失败"))
|
||||
toastCenter.show(success ? "\(kind == .sample ? "样片" : "素材")已提交" : (viewModel.errorMessage ?? "提交失败"))
|
||||
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 {
|
||||
let file: MediaLocalUploadFile
|
||||
|
||||
@ -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 的映射,并同步补充单元测试。
|
||||
|
||||
@ -36,6 +36,8 @@ enum HomeRoute: Hashable {
|
||||
case cloudStorageTransit
|
||||
case materialLibrary
|
||||
case materialUpload
|
||||
case sampleLibrary
|
||||
case sampleUpload
|
||||
case albumList
|
||||
case albumTrailer
|
||||
case modulePlaceholder(uri: String, title: String)
|
||||
|
||||
@ -103,6 +103,10 @@ enum HomeMenuRouter {
|
||||
return .destination(.materialLibrary)
|
||||
case "material_upload":
|
||||
return .destination(.materialUpload)
|
||||
case "sample_management":
|
||||
return .destination(.sampleLibrary)
|
||||
case "sample_upload":
|
||||
return .destination(.sampleUpload)
|
||||
case "album_list":
|
||||
return .destination(.albumList)
|
||||
case "album_trailer":
|
||||
@ -127,8 +131,6 @@ enum HomeMenuRouter {
|
||||
"pm",
|
||||
"pm_manager",
|
||||
"project_edit",
|
||||
"sample_management",
|
||||
"sample_upload",
|
||||
"live_stream_management",
|
||||
"live_album",
|
||||
"scenic_settlement",
|
||||
|
||||
@ -44,6 +44,10 @@ extension HomeRoute {
|
||||
MediaLibraryView()
|
||||
case .materialUpload:
|
||||
MediaLibraryUploadView()
|
||||
case .sampleLibrary:
|
||||
MediaLibraryView(kind: .sample)
|
||||
case .sampleUpload:
|
||||
MediaLibraryUploadView(kind: .sample)
|
||||
case .albumList:
|
||||
AlbumListView()
|
||||
case .albumTrailer:
|
||||
|
||||
@ -76,7 +76,7 @@ final class AssetsAPITests: XCTestCase {
|
||||
let api = AssetsAPI(client: APIClient(session: session))
|
||||
|
||||
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")
|
||||
let query = assetsQueryItems(from: try XCTUnwrap(session.requests.first))
|
||||
@ -87,6 +87,7 @@ final class AssetsAPITests: XCTestCase {
|
||||
XCTAssertEqual(list.order?.totalNum, 12)
|
||||
XCTAssertEqual(list.list.first?.likesCount, 3)
|
||||
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)
|
||||
XCTAssertTrue(detail.mediaList.first?.isVideo == true)
|
||||
}
|
||||
@ -108,8 +109,8 @@ final class AssetsAPITests: XCTestCase {
|
||||
projectId: 0
|
||||
)
|
||||
|
||||
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 1, listingStatus: 1))
|
||||
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 1))
|
||||
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 1, type: 1, listingStatus: 1))
|
||||
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 1, type: 1))
|
||||
try await api.mediaAlbumAddTag(MediaAlbumAddTagRequest(name: "航拍"))
|
||||
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: "航拍"))
|
||||
@ -126,6 +127,52 @@ final class AssetsAPITests: XCTestCase {
|
||||
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 和请求体。
|
||||
func testAlbumRequestsUseExpectedPathQueryAndBodies() async throws {
|
||||
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"}
|
||||
]}}
|
||||
""".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 = """
|
||||
{"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":"备注"}
|
||||
|
||||
@ -95,6 +95,16 @@ final class AssetsViewModelTests: XCTestCase {
|
||||
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 {
|
||||
let api = MockAssetsService()
|
||||
@ -108,6 +118,18 @@ final class AssetsViewModelTests: XCTestCase {
|
||||
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。
|
||||
func testMediaUploadUploadsFilesBeforeSubmit() async {
|
||||
let api = MockAssetsService()
|
||||
@ -145,6 +167,61 @@ final class AssetsViewModelTests: XCTestCase {
|
||||
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 {
|
||||
let api = MockAssetsService()
|
||||
@ -274,11 +351,19 @@ private final class MockAssetsService: AssetsServing {
|
||||
}
|
||||
|
||||
struct MediaListRequest: Equatable {
|
||||
let kind: MediaLibraryKind
|
||||
let keyword: String
|
||||
let status: Int?
|
||||
let page: Int
|
||||
}
|
||||
|
||||
struct ProjectListRequest: Equatable {
|
||||
let scenicId: Int
|
||||
let name: String?
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
}
|
||||
|
||||
struct AlbumFolderListRequest: Equatable {
|
||||
let scenicId: Int
|
||||
let page: Int
|
||||
@ -296,6 +381,7 @@ private final class MockAssetsService: AssetsServing {
|
||||
|
||||
var cloudPages: [ListPayload<CloudDriveFile>] = [ListPayload(total: 0, list: [])]
|
||||
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 albumFilePages: [ListPayload<AlbumFileItem>] = [ListPayload(total: 0, list: [])]
|
||||
var uploadPermission = true
|
||||
@ -306,6 +392,7 @@ private final class MockAssetsService: AssetsServing {
|
||||
var mediaOperationRequests: [MediaAlbumOperationRequest] = []
|
||||
var mediaUploadRequests: [MediaAlbumUploadRequest] = []
|
||||
var mediaEditRequests: [MediaAlbumEditRequest] = []
|
||||
var projectListRequests: [ProjectListRequest] = []
|
||||
var albumFolderListRequests: [AlbumFolderListRequest] = []
|
||||
var albumInfoRequests: [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 {
|
||||
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()
|
||||
}
|
||||
|
||||
/// 返回素材详情。
|
||||
func mediaAlbumDetail(id: Int) async throws -> MediaLibraryDetail {
|
||||
func mediaAlbumDetail(id: Int, kind: MediaLibraryKind) async throws -> MediaLibraryDetail {
|
||||
MediaLibraryDetail.fixture(id: id)
|
||||
}
|
||||
|
||||
@ -377,6 +464,12 @@ private final class MockAssetsService: AssetsServing {
|
||||
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> {
|
||||
albumFolderListRequests.append(AlbumFolderListRequest(scenicId: scenicId, page: page, pageSize: pageSize, name: name, startTime: startTime, endTime: endTime))
|
||||
|
||||
@ -41,6 +41,8 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "material_upload", title: ""), .destination(.materialUpload))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList))
|
||||
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: "结算审核"))
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "sample_upload", title: ""),
|
||||
.destination(.modulePlaceholder(uri: "sample_upload", title: "上传样片"))
|
||||
HomeMenuRouter.resolve(uri: "message_center", title: ""),
|
||||
.destination(.modulePlaceholder(uri: "message_center", title: "消息中心"))
|
||||
)
|
||||
}
|
||||
|
||||
@ -167,10 +169,8 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertTrue(uris.contains("wallet"))
|
||||
XCTAssertTrue(uris.contains("sample_management"))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList))
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "sample_management", title: ""),
|
||||
.destination(.modulePlaceholder(uri: "sample_management", title: "样片管理"))
|
||||
)
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload))
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
| 完成 | 全局状态拆分 | `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 |
|
||||
| 完成 | 登录态缓存 | token 使用 Keychain;账号快照、上次手机号、偏好使用 UserDefaults;冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 |
|
||||
| 完成 | 网络框架 | `APIClient`、`APIRequest`、`APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 |
|
||||
| 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材文件上传处理。 | 后续样片等模块迁移时继续复用。 |
|
||||
| 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材/样片文件上传处理。 | 后续新上传场景继续复用。 |
|
||||
| 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 |
|
||||
| 完成 | 设计常量 | `AppDesign`、`AppMetrics` 统一颜色、字号、间距等通用尺寸。 | 继续替换新页面中的零散常量。 |
|
||||
| 完成 | 文档规范 | 已有 `AGENTS.md` 约束;现有模块均有模块说明文档。 | 后续每个新功能模块同步新增模块 md。 |
|
||||
@ -96,8 +96,8 @@
|
||||
| 完成 | 相册云盘 | 云盘文件/文件夹浏览、搜索筛选、排序、分页、上传、预览、下载、重命名、移动、删除、传输记录。 | 云盘下载只保存到 App 沙盒 `Documents/CloudDownloads`,不写入系统相册。 |
|
||||
| 完成 | 相册管理 | 相册列表、搜索筛选、日期筛选、创建相册、相册详情、图片/视频列表、预览、设置封面、删除文件、编辑名称/备注。 | 后续如旧工程补充更多相册运营动作再继续迁移。 |
|
||||
| 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 |
|
||||
| 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 样片管理仍按独立模块后续迁移。 |
|
||||
| 部分完成 | 样片管理 | 入口已识别,OSS 上传服务已具备。 | 迁移样片列表、上传样片。 |
|
||||
| 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 无。 |
|
||||
| 完成 | 样片管理 | 样片列表、关键词/审核状态筛选、分页、详情、上下架、删除、上传样片、关联项目选择、样片文件 OSS 上传。 | 完整项目管理、样片编辑、视频播放器增强后续单独迁移。 |
|
||||
| 部分完成 | 直播管理 | 入口已识别,进入占位页。 | 迁移直播管理和直播相册。 |
|
||||
| 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 |
|
||||
| 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 |
|
||||
@ -112,7 +112,7 @@
|
||||
|
||||
| 状态 | 模块 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 部分完成 | Assets 模块 | 已迁移相册管理、相册预览上传、相册云盘和素材管理;样片管理、上传样片仍未迁移。 |
|
||||
| 完成 | Assets 模块 | 已迁移相册管理、相册预览上传、相册云盘、素材管理、样片管理和上传样片。 |
|
||||
| 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 |
|
||||
| 未开始 | Core/Push | 旧工程推送能力尚未迁移。 |
|
||||
| 未开始 | Core/Queue | 旧工程队列基础能力尚未迁移。 |
|
||||
@ -131,6 +131,6 @@ xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination
|
||||
## 下一批建议迁移顺序
|
||||
|
||||
1. 打卡点管理/位置上报:依赖当前景区和景点上下文,当前上下文已经稳定。
|
||||
2. 样片管理/上传样片:与素材管理和相册上传相近,可复用素材列表、上传和标签处理经验。
|
||||
3. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾。
|
||||
4. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试。
|
||||
2. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾。
|
||||
3. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试。
|
||||
4. 项目管理:样片上传已具备轻量项目选择,后续可迁移完整项目列表、详情和编辑。
|
||||
|
||||
Reference in New Issue
Block a user