Compare commits
5
Commits
f244f14ab8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0075c130bd | ||
|
|
2b0df49aa7 | ||
|
|
eb86810a77 | ||
|
|
247da63509 | ||
|
|
1a6fde919b |
@@ -115,10 +115,20 @@ export struct ReportListTab {
|
||||
ReportItem(item: ReportListItemWire) {
|
||||
Row({ space: 12 }) {
|
||||
Column({ space: 4 }) {
|
||||
Text(`形象报告 v${item.version ?? 1}`)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(TEXT_PRIMARY)
|
||||
Row({ space: 6 }) {
|
||||
Text(item.tier === 'full' ? `深度诊断报告 v${item.version ?? 1}` : `形象报告 v${item.version ?? 1}`)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(TEXT_PRIMARY)
|
||||
if (item.tier === 'full') {
|
||||
Text('深度')
|
||||
.fontSize(10)
|
||||
.fontColor(ACCENT)
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.borderRadius(6)
|
||||
.backgroundColor('#F7E3EA')
|
||||
}
|
||||
}
|
||||
Text(this.reportTime(item))
|
||||
.fontSize(12)
|
||||
.fontColor(TEXT_SECONDARY)
|
||||
|
||||
@@ -62,15 +62,62 @@ export interface ValidationWire {
|
||||
degraded?: boolean // true = 规则引擎降级生成,UI 需黄条透明提示
|
||||
}
|
||||
|
||||
/** 色板单色(result.full.palette.{best,neutral,avoid}[];hex 为 '#RRGGBB') */
|
||||
export interface PaletteColorWire {
|
||||
name?: string
|
||||
hex?: string
|
||||
}
|
||||
|
||||
/** 十二季色彩定位(result.full.season;analysis 为 LLM 季型解析文案) */
|
||||
export interface FullSeasonWire {
|
||||
code?: string
|
||||
detailed?: string
|
||||
name_cn?: string
|
||||
analysis?: string
|
||||
}
|
||||
|
||||
export interface FullPaletteWire {
|
||||
best?: PaletteColorWire[]
|
||||
neutral?: PaletteColorWire[]
|
||||
avoid?: PaletteColorWire[]
|
||||
}
|
||||
|
||||
export interface FullOutfitWire {
|
||||
name?: string
|
||||
points?: string[]
|
||||
}
|
||||
|
||||
export interface FullSceneWire {
|
||||
key?: string
|
||||
title?: string
|
||||
outfits?: FullOutfitWire[]
|
||||
}
|
||||
|
||||
export interface PlanWeekWire {
|
||||
week?: number
|
||||
title?: string
|
||||
tasks?: string[]
|
||||
}
|
||||
|
||||
/** 深度诊断(tier=full)增量内容:色板 / 场景穿搭 / 30 天计划 */
|
||||
export interface FullResultWire {
|
||||
season?: FullSeasonWire
|
||||
palette?: FullPaletteWire
|
||||
scenes?: FullSceneWire[]
|
||||
plan30?: PlanWeekWire[]
|
||||
}
|
||||
|
||||
export interface ReportResultWire {
|
||||
diagnosis?: DiagnosisWire
|
||||
copy?: CopyWire
|
||||
validation?: ValidationWire
|
||||
full?: FullResultWire
|
||||
}
|
||||
|
||||
/** GET /api/v1/reports/{id} 的响应 */
|
||||
export interface ReportDetailWire {
|
||||
report_id?: string
|
||||
tier?: string // 'basic' | 'full'
|
||||
status?: string
|
||||
version?: number
|
||||
result?: ReportResultWire
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* - access 短令牌放内存 + preferences(local 明文可接受:V1 骨架,无支付;
|
||||
* 正式版应迁 @ohos.security.asset,注释留迁移点)
|
||||
* - refresh 一次性轮换:每次 refresh 换新对
|
||||
* - dev-token 登录:POST /auth/dev-token(后端 DEV_AUTH_ENABLED 本地可用;
|
||||
* 华为账号/短信登录是后续通道,接口按多通道预留)
|
||||
* - dev-token 登录:POST /auth/dev-token(后端 DEV_AUTH_ENABLED 本地可用,
|
||||
* 挪进登录页「高级设置(开发者)」折叠区,仅供本机联调)
|
||||
* - 华为账号登录:POST /auth/huawei(Account Kit idToken 换双令牌)
|
||||
* - 邮箱验证码登录:POST /auth/email/code + /auth/email/login
|
||||
* - ArkTS 约束:所有请求/响应都是具名 interface;字面量先声明类型再传
|
||||
*/
|
||||
|
||||
@@ -29,6 +31,11 @@ interface AuthWireResp {
|
||||
user_id: string
|
||||
}
|
||||
|
||||
interface EmailCodeWireResp {
|
||||
sent: boolean
|
||||
cooldown_seconds: number
|
||||
}
|
||||
|
||||
const PREF_NAME = 'meida_auth'
|
||||
const KEY_TOKENS = 'tokens'
|
||||
const KEY_BASE_URL = 'base_url'
|
||||
@@ -74,12 +81,38 @@ export class AuthStore {
|
||||
return await AuthStore.prefs!.get(KEY_BASE_URL, DEFAULT_BASE_URL) as string
|
||||
}
|
||||
|
||||
/** dev-token 登录(本地后端开发通道)。失败由 ApiClient 抛 ApiError。 */
|
||||
/** dev-token 登录(本机联调通道,入口在登录页高级设置折叠区)。 */
|
||||
static async loginByDevToken(deviceKey: string): Promise<void> {
|
||||
const req: Record<string, Object> = { 'device_key': deviceKey }
|
||||
// 登录本身不能走带鉴权的 post()——ApiClient 对未登录一律 40100 短路,
|
||||
// 登录接口会被自己的守卫拦死(鸡生蛋)。必须走无鉴权通道。
|
||||
const data = await ApiClient.postWithoutAuth<AuthWireResp>('/api/v1/auth/dev-token', req)
|
||||
await AuthStore.loginAndSave('/api/v1/auth/dev-token', req)
|
||||
}
|
||||
|
||||
/** 华为账号登录:Account Kit idToken 换双令牌。失败由 ApiClient 抛 ApiError。 */
|
||||
static async loginByHuaweiIdToken(idToken: string): Promise<void> {
|
||||
const req: Record<string, Object> = { 'id_token': idToken }
|
||||
await AuthStore.loginAndSave('/api/v1/auth/huawei', req)
|
||||
}
|
||||
|
||||
/** 发送邮箱验证码。成功返回服务端给的 cooldown_seconds(前端冷却倒计时用)。 */
|
||||
static async sendEmailCode(email: string): Promise<number> {
|
||||
const req: Record<string, Object> = { 'email': email }
|
||||
const data = await ApiClient.postWithoutAuth<EmailCodeWireResp>('/api/v1/auth/email/code', req)
|
||||
return data.cooldown_seconds
|
||||
}
|
||||
|
||||
/** 邮箱验证码登录:同双令牌持久化。 */
|
||||
static async loginByEmail(email: string, code: string): Promise<void> {
|
||||
const req: Record<string, Object> = { 'email': email, 'code': code }
|
||||
await AuthStore.loginAndSave('/api/v1/auth/email/login', req)
|
||||
}
|
||||
|
||||
/**
|
||||
* 各登录通道公共收尾:无鉴权通道换双令牌并持久化。
|
||||
* 登录本身不能走带鉴权的 post()——ApiClient 对未登录一律 40100 短路,
|
||||
* 登录接口会被自己的守卫拦死(鸡生蛋)。必须走无鉴权通道。
|
||||
*/
|
||||
private static async loginAndSave(path: string, req: Record<string, Object>): Promise<void> {
|
||||
const data = await ApiClient.postWithoutAuth<AuthWireResp>(path, req)
|
||||
await AuthStore.saveTokens(AuthStore.fromWire(data))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 华为账号授权登录封装(Account Kit)。
|
||||
*
|
||||
* 本机 SDK 声明为准(sdk/default/hms/ets/api/@hms.core.authentication.d.ts):
|
||||
* - `new authentication.HuaweiIDProvider().createAuthorizationWithHuaweiIDRequest()`
|
||||
* - `new authentication.AuthenticationController(context).executeRequest(request)`
|
||||
* → Promise<AuthenticationResponse>,成功时 as AuthorizationWithHuaweiIDResponse
|
||||
* 取 `data.idToken`(即后端 POST /auth/huawei 的 id_token)
|
||||
* - 用户取消授权:executeRequest reject,BusinessError.code = 1001502012
|
||||
* (AuthenticationErrorCode.USER_CANCELED),UI 层静默处理
|
||||
*
|
||||
* client_id 来自 module.json5 模块级 metadata(AGC 平台申请后填入);
|
||||
* 仍是占位值时 LoginPage 直接拦截给提示,不会真正调 SDK。
|
||||
*/
|
||||
|
||||
import { authentication } from '@kit.AccountKit'
|
||||
import { bundleManager } from '@kit.AbilityKit'
|
||||
import { BusinessError } from '@kit.BasicServicesKit'
|
||||
import { ContextHolder } from './ContextHolder'
|
||||
|
||||
/** module.json5 metadata 里的 client_id 占位值(未接 AGC 前保持此值)。 */
|
||||
export const HUAWEI_CLIENT_ID_PLACEHOLDER: string = 'TODO_AGC_CLIENT_ID'
|
||||
|
||||
/** Account Kit 错误码:用户取消授权。 */
|
||||
const ERR_USER_CANCELED: number = 1001502012
|
||||
|
||||
export class HuaweiLogin {
|
||||
/** 读取本模块 module.json5 里配置的 client_id;读取失败或未配置返回占位值。 */
|
||||
static async resolveClientId(): Promise<string> {
|
||||
try {
|
||||
const flag = bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_HAP_MODULE |
|
||||
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_METADATA
|
||||
const info = await bundleManager.getBundleInfoForSelf(flag)
|
||||
for (const hap of info.hapModulesInfo) {
|
||||
for (const item of hap.metadata) {
|
||||
if (item.name === 'client_id') {
|
||||
return item.value
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// 读取失败按未配置处理,页面给「暂未开放」提示
|
||||
}
|
||||
return HUAWEI_CLIENT_ID_PLACEHOLDER
|
||||
}
|
||||
|
||||
/** 是否已完成 AGC 配置(真实 client_id 才能拉起授权页)。 */
|
||||
static isConfigured(clientId: string): boolean {
|
||||
return clientId !== '' && clientId !== HUAWEI_CLIENT_ID_PLACEHOLDER
|
||||
}
|
||||
|
||||
/** 是否用户主动取消授权(取消不算错误,UI 静默返回)。 */
|
||||
static isUserCancelled(e: Object): boolean {
|
||||
if (!(e instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
const be = e as BusinessError
|
||||
return be.code === ERR_USER_CANCELED
|
||||
}
|
||||
|
||||
/** 拉起华为账号授权页(非静默,scopes: openid + profile),返回 idToken。 */
|
||||
static async requestIdToken(): Promise<string> {
|
||||
const controller = new authentication.AuthenticationController(ContextHolder.get())
|
||||
const provider = new authentication.HuaweiIDProvider()
|
||||
const request = provider.createAuthorizationWithHuaweiIDRequest()
|
||||
request.scopes = ['openid', 'profile']
|
||||
request.forceAuthorization = true
|
||||
const response = await controller.executeRequest(request)
|
||||
const authResp = response as authentication.AuthorizationWithHuaweiIDResponse
|
||||
const idToken = authResp.data?.idToken
|
||||
if (idToken === undefined || idToken === '') {
|
||||
throw new Error('华为授权成功但未返回登录凭证')
|
||||
}
|
||||
return idToken
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import { image } from '@kit.ImageKit'
|
||||
import { util } from '@kit.ArkTS'
|
||||
import { BusinessError } from '@kit.BasicServicesKit'
|
||||
import { photoAccessHelper } from '@kit.MediaLibraryKit'
|
||||
import { cameraPicker } from '@kit.CameraKit'
|
||||
import { camera } from '@kit.CameraKit'
|
||||
@@ -70,6 +71,12 @@ export class ImageCodec {
|
||||
)
|
||||
}
|
||||
|
||||
/** 用户取消判定:多数路径取消返回空数组,但预览态/部分 ROM 会 reject 13900042。 */
|
||||
private static isUserCancel(e: Object): boolean {
|
||||
const code: number | undefined = (e as BusinessError).code
|
||||
return code === 13900042
|
||||
}
|
||||
|
||||
/** 相册选 1 张。用户取消返回 undefined。 */
|
||||
static async pickFromGallery(): Promise<PickedImage | undefined> {
|
||||
const picker = new photoAccessHelper.PhotoViewPicker()
|
||||
@@ -90,6 +97,9 @@ export class ImageCodec {
|
||||
return { uri: res.photoUris[0], width: 0, height: 0 }
|
||||
} catch (e) {
|
||||
hilog.error(IMG_DOMAIN, IMG_TAG, 'gallery: pick failed: %{public}s', String(e))
|
||||
if (ImageCodec.isUserCancel(e)) {
|
||||
return undefined // 取消不报错(页面保持原状,不弹「读取照片失败」)
|
||||
}
|
||||
throw e as Error
|
||||
}
|
||||
}
|
||||
@@ -109,6 +119,9 @@ export class ImageCodec {
|
||||
return { uri: res.resultUri, width: 0, height: 0 }
|
||||
} catch (e) {
|
||||
hilog.error(IMG_DOMAIN, IMG_TAG, 'camera: pick failed: %{public}s', String(e))
|
||||
if (ImageCodec.isUserCancel(e)) {
|
||||
return undefined // 取消不报错
|
||||
}
|
||||
throw e as Error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* params 键名精确为 gender/birthday/height_cm/weight_kg,值全部为 string
|
||||
* (接收端 ReportCreatePage 按 string 读取);跳转失败页内红字兜底。
|
||||
* - 职业仅本地留存(AppStorage),不进提交体(需求 §3.6/验收 4)。
|
||||
* - 全部字段做 AppStorage 草稿(f3_diagnosis_*):aboutToAppear 恢复、
|
||||
* 每次变更写回(write-through),离开/重进诊断流程不丢已填内容。
|
||||
*/
|
||||
|
||||
import { router, componentUtils } from '@kit.ArkUI'
|
||||
@@ -50,6 +52,11 @@ const BIRTHDAY_FALLBACK_SELECTED: Date = new Date(2000, 0, 1)
|
||||
|
||||
/** 职业为本地留存字段(后端问卷 schema 无 occupation,Phase B 随 schema 扩展提交) */
|
||||
const OCCUPATION_STORAGE_KEY: string = 'f3_diagnosis_occupation'
|
||||
// 表单草稿(F3 跨页面保留):离开/重进诊断流程不丢已填内容,提交成功也不清
|
||||
const DRAFT_GENDER_KEY: string = 'f3_diagnosis_gender'
|
||||
const DRAFT_BIRTHDAY_KEY: string = 'f3_diagnosis_birthday'
|
||||
const DRAFT_HEIGHT_KEY: string = 'f3_diagnosis_height'
|
||||
const DRAFT_WEIGHT_KEY: string = 'f3_diagnosis_weight'
|
||||
|
||||
function buildRangeOptions(min: number, max: number): string[] {
|
||||
const arr: string[] = []
|
||||
@@ -78,6 +85,39 @@ struct DiagnosisFormPage {
|
||||
@State navError: string = ''
|
||||
private scroller: Scroller = new Scroller()
|
||||
|
||||
aboutToAppear(): void {
|
||||
// 恢复草稿:用户离开诊断流程(回首页/切页)后重进不丢已填内容
|
||||
const gender = AppStorage.get<string>(DRAFT_GENDER_KEY)
|
||||
if (gender !== undefined) {
|
||||
this.gender = gender
|
||||
}
|
||||
const birthday = AppStorage.get<string>(DRAFT_BIRTHDAY_KEY)
|
||||
if (birthday !== undefined) {
|
||||
this.birthday = birthday
|
||||
}
|
||||
const height = AppStorage.get<string>(DRAFT_HEIGHT_KEY)
|
||||
if (height !== undefined) {
|
||||
this.heightInput = height
|
||||
}
|
||||
const weight = AppStorage.get<string>(DRAFT_WEIGHT_KEY)
|
||||
if (weight !== undefined) {
|
||||
this.weightInput = weight
|
||||
}
|
||||
const occupation = AppStorage.get<string>(OCCUPATION_STORAGE_KEY)
|
||||
if (occupation !== undefined) {
|
||||
this.occupation = occupation
|
||||
}
|
||||
}
|
||||
|
||||
/** 草稿写回(write-through):每次字段变更即落 AppStorage。 */
|
||||
private saveDraft(): void {
|
||||
AppStorage.setOrCreate<string>(DRAFT_GENDER_KEY, this.gender)
|
||||
AppStorage.setOrCreate<string>(DRAFT_BIRTHDAY_KEY, this.birthday)
|
||||
AppStorage.setOrCreate<string>(DRAFT_HEIGHT_KEY, this.heightInput)
|
||||
AppStorage.setOrCreate<string>(DRAFT_WEIGHT_KEY, this.weightInput)
|
||||
AppStorage.setOrCreate<string>(OCCUPATION_STORAGE_KEY, this.occupation)
|
||||
}
|
||||
|
||||
/** 由出生日期派生只读年龄(周岁)。 */
|
||||
private ageOf(): number {
|
||||
if (this.birthday === '') {
|
||||
@@ -114,6 +154,7 @@ struct DiagnosisFormPage {
|
||||
onDateAccept: (value: Date): void => {
|
||||
this.birthday = formatDate(value)
|
||||
this.birthdayErr = ''
|
||||
this.saveDraft()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -131,6 +172,7 @@ struct DiagnosisFormPage {
|
||||
if (idx >= 0 && idx < HEIGHT_OPTIONS.length) {
|
||||
this.heightInput = HEIGHT_OPTIONS[idx]
|
||||
this.heightErr = ''
|
||||
this.saveDraft()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -148,6 +190,7 @@ struct DiagnosisFormPage {
|
||||
if (idx >= 0 && idx < WEIGHT_OPTIONS.length) {
|
||||
this.weightInput = WEIGHT_OPTIONS[idx]
|
||||
this.weightErr = ''
|
||||
this.saveDraft()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -215,8 +258,8 @@ struct DiagnosisFormPage {
|
||||
this.scrollToFirstError()
|
||||
return
|
||||
}
|
||||
// 职业仅本地留存,不进提交体(需求 §3.6/验收 4)
|
||||
AppStorage.setOrCreate<string>(OCCUPATION_STORAGE_KEY, this.occupation)
|
||||
// 职业仅本地留存,不进提交体(需求 §3.6/验收 4);整份草稿一并写回
|
||||
this.saveDraft()
|
||||
// 四个值全部为 string,接收端 ReportCreatePage 按 string 读取
|
||||
const params: Record<string, Object> = {
|
||||
'gender': this.gender,
|
||||
@@ -260,6 +303,7 @@ struct DiagnosisFormPage {
|
||||
if (idx >= 0 && idx < GENDER_OPTIONS.length) {
|
||||
this.gender = GENDER_OPTIONS[idx]
|
||||
this.genderErr = ''
|
||||
this.saveDraft()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -278,6 +322,7 @@ struct DiagnosisFormPage {
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
.onClick(() => {
|
||||
this.occupation = this.occupation === o ? '' : o
|
||||
this.saveDraft()
|
||||
})
|
||||
}, (o: string) => o)
|
||||
}
|
||||
|
||||
@@ -453,14 +453,21 @@ struct FittingRoomPage {
|
||||
return false
|
||||
}
|
||||
|
||||
/** 回到选人台:清掉上一轮生成/保存的残留。 */
|
||||
/** 清掉上一轮生成/保存的残留(resultSrc 必须一起清,否则效果图仍挂在预览区)。 */
|
||||
private resetResult(): void {
|
||||
this.resultUrl = ''
|
||||
this.resultLocalPath = ''
|
||||
this.resultSrc = ''
|
||||
this.showOriginal = false
|
||||
this.statusText = ''
|
||||
}
|
||||
|
||||
/** 「再来一次」:连本人照一起清,回到初始进入界面(可重新拍照/相册选图)。 */
|
||||
private resetToEntry(): void {
|
||||
this.humanUri = ''
|
||||
this.resetResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成成功即转存本机:下载结果到 filesDir/results/tryon_last.jpg。
|
||||
* 下载前先清旧文件——request.downloadFile 不能覆盖已存在文件(TryonPage #21 同款坑)。
|
||||
@@ -1206,7 +1213,7 @@ struct FittingRoomPage {
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.resetResult()
|
||||
this.resetToEntry()
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
@@ -1317,7 +1324,7 @@ struct FittingRoomPage {
|
||||
},
|
||||
onRetry: (): void => {
|
||||
this.viewerOpen = false
|
||||
this.resetResult()
|
||||
this.resetToEntry()
|
||||
},
|
||||
onClose: (): void => {
|
||||
this.viewerOpen = false
|
||||
|
||||
@@ -303,7 +303,8 @@ struct HairColorPage {
|
||||
.backgroundColor(COLOR_IMAGE_BG)
|
||||
.alignSelf(ItemAlign.Start)
|
||||
|
||||
// 图片区(单区域):无照片=上传入口;生成中=loading;按页签显示原图/效果图
|
||||
// 图片区(单区域,定高 320):无照片=上传入口;生成中=loading;按页签显示原图/效果图。
|
||||
// 注意 Stack 必须显式定高:内部额度胶囊 Row 用 height(100%),不定高会被 Scroll 视口撑飞
|
||||
Stack({ alignContent: Alignment.BottomStart }) {
|
||||
if (this.photoUri.length === 0) {
|
||||
Column({ space: 6 }) {
|
||||
@@ -321,11 +322,9 @@ struct HairColorPage {
|
||||
.width('100%').height(320).justifyContent(FlexAlign.Center)
|
||||
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
|
||||
} else if (this.imageTab === 1 && this.resultPm !== undefined) {
|
||||
// 顶部对齐裁切:容器定高 clip,图片只定宽按原比缩放,头顶/发型不被居中裁掉
|
||||
Column() {
|
||||
Image(this.resultPm).width('100%').objectFit(ImageFit.Cover)
|
||||
}
|
||||
.width('100%').height(320).clip(true).borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
|
||||
// Cover 居中裁切:人脸通常在画面中央,头顶/下巴各让一点比只切下巴安全
|
||||
Image(this.resultPm)
|
||||
.width('100%').height(320).objectFit(ImageFit.Cover).borderRadius(12)
|
||||
Text('效果图')
|
||||
.fontSize(11).fontColor(Color.White)
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
@@ -333,11 +332,8 @@ struct HairColorPage {
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
.margin(8)
|
||||
} else {
|
||||
// 顶部对齐裁切:同上
|
||||
Column() {
|
||||
Image(this.photoUri).width('100%').objectFit(ImageFit.Cover)
|
||||
}
|
||||
.width('100%').height(320).clip(true).borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
|
||||
Image(this.photoUri)
|
||||
.width('100%').height(320).objectFit(ImageFit.Cover).borderRadius(12)
|
||||
Text('原图')
|
||||
.fontSize(11).fontColor(Color.White)
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
@@ -363,6 +359,7 @@ struct HairColorPage {
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height(320)
|
||||
|
||||
// 入口文字链:相册/拍照 + 保存(有结果才可点)
|
||||
Row({ space: 16 }) {
|
||||
|
||||
@@ -340,7 +340,8 @@ struct HairDesignPage {
|
||||
.backgroundColor(COLOR_IMAGE_BG)
|
||||
.alignSelf(ItemAlign.Start)
|
||||
|
||||
// 图片区(单区域):无照片=上传入口;生成中=loading;按页签显示原图/效果图
|
||||
// 图片区(单区域,定高 320):无照片=上传入口;生成中=loading;按页签显示原图/效果图。
|
||||
// 注意 Stack 必须显式定高:内部额度胶囊 Row 用 height(100%),不定高会被 Scroll 视口撑飞
|
||||
Stack({ alignContent: Alignment.BottomStart }) {
|
||||
if (this.photoUri.length === 0) {
|
||||
Column({ space: 6 }) {
|
||||
@@ -358,11 +359,9 @@ struct HairDesignPage {
|
||||
.width('100%').height(320).justifyContent(FlexAlign.Center)
|
||||
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
|
||||
} else if (this.imageTab === 1 && this.resultPm !== undefined) {
|
||||
// 顶部对齐裁切:容器定高 clip,图片只定宽按原比缩放,头顶/发型不被居中裁掉
|
||||
Column() {
|
||||
Image(this.resultPm).width('100%').objectFit(ImageFit.Cover)
|
||||
}
|
||||
.width('100%').height(320).clip(true).borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
|
||||
// Cover 居中裁切:人脸通常在画面中央,头顶/下巴各让一点比只切下巴安全
|
||||
Image(this.resultPm)
|
||||
.width('100%').height(320).objectFit(ImageFit.Cover).borderRadius(12)
|
||||
Text('效果图')
|
||||
.fontSize(11).fontColor(Color.White)
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
@@ -370,11 +369,8 @@ struct HairDesignPage {
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
.margin(8)
|
||||
} else {
|
||||
// 顶部对齐裁切:同上
|
||||
Column() {
|
||||
Image(this.photoUri).width('100%').objectFit(ImageFit.Cover)
|
||||
}
|
||||
.width('100%').height(320).clip(true).borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
|
||||
Image(this.photoUri)
|
||||
.width('100%').height(320).objectFit(ImageFit.Cover).borderRadius(12)
|
||||
Text('原图')
|
||||
.fontSize(11).fontColor(Color.White)
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
@@ -400,6 +396,7 @@ struct HairDesignPage {
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height(320)
|
||||
|
||||
// 入口文字链:相册/拍照 + 保存(有结果才可点)
|
||||
Row({ space: 16 }) {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* (组件级复用允许 import,独立路由页无底部 Tab 会把用户锁死在页面里)
|
||||
* - 请求只走 ApiClient;今日额度失败静默降级为 '—'(首页不打扰用户,不弹错误态)
|
||||
* - 响应 wire 类型用具名 interface(字段对齐后端 JSON);泛型调用显式类型参数
|
||||
* - 可选路由参数 initialTab(number 0~3):外部页面可指定落地 Tab
|
||||
* (如报告结果页「查看历史报告」传 1 落地「报告」Tab)
|
||||
*/
|
||||
|
||||
import { router } from '@kit.ArkUI'
|
||||
@@ -45,14 +47,27 @@ struct HomePage {
|
||||
@State quotaRemaining: string = '…'
|
||||
@State quotaNetError: boolean = false // 额度拉取遇网络错误(P0-6):配额卡位置换 NetworkBanner
|
||||
@State reportRefreshTick: number = 0 // 自增通知报告 Tab 刷新(N2)
|
||||
private tabsController: TabsController = new TabsController() // 供 initialTab 参数落地指定 Tab
|
||||
|
||||
aboutToAppear(): void {
|
||||
// 可选参数 initialTab:外部页面(如报告结果页「查看历史报告」)可指定落地 Tab
|
||||
const params = router.getParams() as Record<string, Object> | undefined
|
||||
if (params !== undefined && typeof params['initialTab'] === 'number') {
|
||||
const idx = params['initialTab'] as number
|
||||
if (idx >= 0 && idx <= 3) {
|
||||
this.currentTabIndex = idx
|
||||
}
|
||||
}
|
||||
this.dateLine = this.buildDateLine()
|
||||
this.loadQuota()
|
||||
}
|
||||
|
||||
/** 从报告结果页等返回(应用壳重新可见):当前停在报告 Tab 时刷新列表(N2)。 */
|
||||
onPageShow(): void {
|
||||
// initialTab 落地:Tabs 已挂载后 controller 才生效(aboutToAppear 里调用是空操作)
|
||||
if (this.currentTabIndex !== 0) {
|
||||
this.tabsController.changeIndex(this.currentTabIndex)
|
||||
}
|
||||
if (this.currentTabIndex === 1) {
|
||||
this.reportRefreshTick += 1
|
||||
}
|
||||
@@ -248,7 +263,7 @@ struct HomePage {
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Tabs({ barPosition: BarPosition.End }) {
|
||||
Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) {
|
||||
TabContent() {
|
||||
this.HomeTab()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* LoginPage(F1)—— dev-token 开发登录页。
|
||||
* LoginPage(F1)—— 正式登录页:华为账号登录 + 邮箱验证码登录。
|
||||
*
|
||||
* 闭环:设备标识(随机生成一次并持久化,可改)→ AuthStore.loginByDevToken
|
||||
* → 成功 router.replaceUrl HomePage;失败 catch ApiError 展示 message+hint。
|
||||
* 高级设置:折叠区编辑 baseUrl(AuthStore.setBaseUrl),真机连开发机靠它。
|
||||
* 主区(白卡):
|
||||
* - 华为账号一键登录:Account Kit 拉起授权页拿 idToken → /auth/huawei
|
||||
* (client_id 未配置时给「暂未开放」提示,不调 SDK)
|
||||
* - 邮箱验证码登录:sendEmailCode(60s 冷却倒计时)→ /auth/email/login
|
||||
* 高级设置(开发者,默认收起):dev-token 内测登录 + 服务器地址编辑,本机联调用。
|
||||
* 已登录态进入本页:aboutToAppear 直接跳 HomePage。
|
||||
*
|
||||
* ArkTS 约束(对齐 core/AuthStore 风格):
|
||||
@@ -13,14 +15,22 @@
|
||||
|
||||
import router from '@ohos.router'
|
||||
import preferences from '@ohos.data.preferences'
|
||||
import { promptAction } from '@kit.ArkUI'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
import { AuthStore, DEFAULT_BASE_URL } from '../core/AuthStore'
|
||||
import { ContextHolder } from '../core/ContextHolder'
|
||||
import { HuaweiLogin } from '../core/HuaweiLogin'
|
||||
import { COLOR_PRIMARY } from '../core/Theme'
|
||||
import { ErrorBanner } from '../components/f1/ErrorBanner'
|
||||
|
||||
const PREF_NAME_LOGIN = 'meida_login'
|
||||
const KEY_DEVICE_KEY = 'device_key'
|
||||
const EMAIL_CODE_LENGTH: number = 6
|
||||
|
||||
/** 宽松邮箱校验:非空局部 @ 非空局部 . 非空局部。 */
|
||||
function isEmail(text: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)
|
||||
}
|
||||
|
||||
/** 随机设备标识:dev- + 16 位 hex,生成一次后随 preferences 持久化。 */
|
||||
function generateDeviceKey(): string {
|
||||
@@ -35,20 +45,32 @@ function generateDeviceKey(): string {
|
||||
@Entry
|
||||
@Component
|
||||
struct LoginPage {
|
||||
@State huaweiClientId: string = ''
|
||||
@State huaweiLogging: boolean = false
|
||||
@State emailInput: string = ''
|
||||
@State emailCode: string = ''
|
||||
@State sendingCode: boolean = false
|
||||
@State sendCooldown: number = 0
|
||||
@State emailLogging: boolean = false
|
||||
@State deviceKey: string = ''
|
||||
@State devLogging: boolean = false
|
||||
@State baseUrlInput: string = ''
|
||||
@State showAdvanced: boolean = false
|
||||
@State logging: boolean = false
|
||||
@State savingUrl: boolean = false
|
||||
@State errMessage: string = ''
|
||||
@State errHint: string = ''
|
||||
@State savedTip: string = ''
|
||||
private cooldownTimer: number = -1
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.bootstrap()
|
||||
}
|
||||
|
||||
/** 具名 async 初始化:确保令牌已载入 → 已登录直跳 → 回填设备标识与地址。 */
|
||||
aboutToDisappear(): void {
|
||||
this.stopCooldownTick()
|
||||
}
|
||||
|
||||
/** 具名 async 初始化:令牌载入 → 已登录直跳 → 回填表单与 client_id。 */
|
||||
private async bootstrap(): Promise<void> {
|
||||
try {
|
||||
await AuthStore.init()
|
||||
@@ -73,11 +95,112 @@ struct LoginPage {
|
||||
this.deviceKey = generateDeviceKey()
|
||||
}
|
||||
this.baseUrlInput = await AuthStore.getBaseUrl()
|
||||
this.huaweiClientId = await HuaweiLogin.resolveClientId()
|
||||
}
|
||||
|
||||
/** 登录主流程:校验 → loading → dev-token → 跳首页;失败横幅展示。 */
|
||||
private async doLogin(): Promise<void> {
|
||||
if (this.logging) {
|
||||
/** 华为账号登录:占位 client_id 直接提示;取消静默;其余 ApiError 走横幅。 */
|
||||
private async doHuaweiLogin(): Promise<void> {
|
||||
if (this.huaweiLogging) {
|
||||
return
|
||||
}
|
||||
this.errMessage = ''
|
||||
this.errHint = ''
|
||||
if (!HuaweiLogin.isConfigured(this.huaweiClientId)) {
|
||||
this.errMessage = '华为登录暂未开放'
|
||||
this.errHint = '请先用邮箱验证码登录,正式开放后第一时间支持'
|
||||
return
|
||||
}
|
||||
this.huaweiLogging = true
|
||||
try {
|
||||
const idToken = await HuaweiLogin.requestIdToken()
|
||||
await AuthStore.loginByHuaweiIdToken(idToken)
|
||||
router.replaceUrl({ url: 'pages/HomePage' })
|
||||
} catch (e) {
|
||||
if (HuaweiLogin.isUserCancelled(e)) {
|
||||
// 用户取消授权:不报错
|
||||
} else if (e instanceof ApiError) {
|
||||
this.errMessage = e.message
|
||||
this.errHint = e.hint !== undefined ? e.hint : ''
|
||||
} else {
|
||||
this.errMessage = '华为授权失败,请稍后重试'
|
||||
this.errHint = '也可使用邮箱验证码登录'
|
||||
}
|
||||
} finally {
|
||||
this.huaweiLogging = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送邮箱验证码:校验格式 → 请求 → 按服务端 cooldown 启动倒计时。 */
|
||||
private async doSendCode(): Promise<void> {
|
||||
if (this.sendingCode || this.sendCooldown > 0) {
|
||||
return
|
||||
}
|
||||
const email = this.emailInput.trim()
|
||||
this.errMessage = ''
|
||||
this.errHint = ''
|
||||
if (!isEmail(email)) {
|
||||
this.errMessage = '邮箱格式不正确'
|
||||
this.errHint = '示例:name@example.com'
|
||||
return
|
||||
}
|
||||
this.sendingCode = true
|
||||
try {
|
||||
const cooldown = await AuthStore.sendEmailCode(email)
|
||||
this.sendCooldown = cooldown > 0 ? cooldown : 60
|
||||
this.startCooldownTick()
|
||||
promptAction.showToast({ message: '验证码已发送,请查收邮件', duration: 2000 })
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
this.errMessage = e.message
|
||||
this.errHint = e.hint !== undefined ? e.hint : ''
|
||||
} else {
|
||||
this.errMessage = '验证码发送失败,请检查网络后重试'
|
||||
this.errHint = ''
|
||||
}
|
||||
} finally {
|
||||
this.sendingCode = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 邮箱验证码登录:校验 → 请求 → 跳首页;失败横幅展示。 */
|
||||
private async doEmailLogin(): Promise<void> {
|
||||
if (this.emailLogging) {
|
||||
return
|
||||
}
|
||||
const email = this.emailInput.trim()
|
||||
const code = this.emailCode.trim()
|
||||
this.errMessage = ''
|
||||
this.errHint = ''
|
||||
if (!isEmail(email)) {
|
||||
this.errMessage = '邮箱格式不正确'
|
||||
this.errHint = '示例:name@example.com'
|
||||
return
|
||||
}
|
||||
if (code.length !== EMAIL_CODE_LENGTH) {
|
||||
this.errMessage = '请输入 6 位数字验证码'
|
||||
this.errHint = ''
|
||||
return
|
||||
}
|
||||
this.emailLogging = true
|
||||
try {
|
||||
await AuthStore.loginByEmail(email, code)
|
||||
router.replaceUrl({ url: 'pages/HomePage' })
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
this.errMessage = e.message
|
||||
this.errHint = e.hint !== undefined ? e.hint : ''
|
||||
} else {
|
||||
this.errMessage = '登录失败,请检查网络后重试'
|
||||
this.errHint = ''
|
||||
}
|
||||
} finally {
|
||||
this.emailLogging = false
|
||||
}
|
||||
}
|
||||
|
||||
/** dev-token 内测登录(高级设置里,本机联调用)。 */
|
||||
private async doDevLogin(): Promise<void> {
|
||||
if (this.devLogging) {
|
||||
return
|
||||
}
|
||||
const key = this.deviceKey.trim()
|
||||
@@ -89,7 +212,7 @@ struct LoginPage {
|
||||
this.errHint = '可点「随机生成」重新生成一个'
|
||||
return
|
||||
}
|
||||
this.logging = true
|
||||
this.devLogging = true
|
||||
this.persistDeviceKey(key)
|
||||
try {
|
||||
await AuthStore.loginByDevToken(key)
|
||||
@@ -99,12 +222,11 @@ struct LoginPage {
|
||||
this.errMessage = e.message
|
||||
this.errHint = e.hint !== undefined ? e.hint : ''
|
||||
} else {
|
||||
// 连接层失败(未进统一信封,#25):给内测口径指引,不改 ApiClient 全局文案
|
||||
this.errMessage = '无法连接内测服务,请确认已连接指定网络,或联系测试人员'
|
||||
this.errHint = ''
|
||||
}
|
||||
} finally {
|
||||
this.logging = false
|
||||
this.devLogging = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +241,24 @@ struct LoginPage {
|
||||
}
|
||||
}
|
||||
|
||||
private startCooldownTick(): void {
|
||||
this.stopCooldownTick()
|
||||
this.cooldownTimer = setInterval(() => {
|
||||
if (this.sendCooldown > 0) {
|
||||
this.sendCooldown -= 1
|
||||
} else {
|
||||
this.stopCooldownTick()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
private stopCooldownTick(): void {
|
||||
if (this.cooldownTimer !== -1) {
|
||||
clearInterval(this.cooldownTimer)
|
||||
this.cooldownTimer = -1
|
||||
}
|
||||
}
|
||||
|
||||
private async saveBaseUrl(): Promise<void> {
|
||||
const url = this.baseUrlInput.trim()
|
||||
this.savedTip = ''
|
||||
@@ -170,7 +310,7 @@ struct LoginPage {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column({ space: 22 }) {
|
||||
// 品牌区
|
||||
// 品牌区(正式产品口径)
|
||||
Column({ space: 10 }) {
|
||||
Text('美搭')
|
||||
.fontSize(48)
|
||||
@@ -180,54 +320,94 @@ struct LoginPage {
|
||||
Text('AI 穿搭美学助手 · 每天三分钟,搭出好气质')
|
||||
.fontSize(14)
|
||||
.fontColor('#8A8A8A')
|
||||
// 内测标识(#25):常驻,不藏进高级设置
|
||||
Text('内测版 · 需连接指定网络后使用')
|
||||
.fontSize(12)
|
||||
.fontColor('#B3554D')
|
||||
.padding({ left: 14, right: 14, top: 5, bottom: 5 })
|
||||
.borderRadius(14)
|
||||
.backgroundColor('#F7E3EA')
|
||||
}
|
||||
.margin({ top: 72, bottom: 8 })
|
||||
|
||||
// 内测账号标识(#25:话术去开发黑话,说明账号机制)
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text('内测账号标识')
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#2B2B2B')
|
||||
Blank()
|
||||
Text('随机生成')
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_PRIMARY)
|
||||
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor('#F7E3EA')
|
||||
.onClick(() => this.regenDeviceKey())
|
||||
// 主登录卡:华为账号 + 邮箱验证码
|
||||
Column({ space: 14 }) {
|
||||
Button(this.huaweiLogging ? '正在拉起华为授权…' : '华为账号一键登录')
|
||||
.width('100%')
|
||||
.height(50)
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.borderRadius(25)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.fontColor('#FFFFFF')
|
||||
.enabled(!this.huaweiLogging)
|
||||
.onClick(() => this.doHuaweiLogin())
|
||||
|
||||
// 分隔线:或使用邮箱登录
|
||||
Row({ space: 12 }) {
|
||||
Divider()
|
||||
.layoutWeight(1)
|
||||
.strokeWidth(1)
|
||||
.color('#E5E7EB')
|
||||
Text('或使用邮箱登录')
|
||||
.fontSize(12)
|
||||
.fontColor('#B3B3B3')
|
||||
Divider()
|
||||
.layoutWeight(1)
|
||||
.strokeWidth(1)
|
||||
.color('#E5E7EB')
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
TextInput({ text: this.deviceKey, placeholder: 'dev-xxxxxxxxxxxxxxxx' })
|
||||
TextInput({ text: this.emailInput, placeholder: '邮箱地址' })
|
||||
.height(48)
|
||||
.fontSize(15)
|
||||
.type(InputType.Email)
|
||||
.borderRadius(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.backgroundColor('#F7F5F6')
|
||||
.padding({ left: 14, right: 14 })
|
||||
.onChange((value: string) => {
|
||||
this.deviceKey = value
|
||||
this.emailInput = value
|
||||
})
|
||||
|
||||
Text('无需注册手机号;同一标识对应同一账号,换一个标识相当于新账号')
|
||||
.fontSize(11)
|
||||
.fontColor('#B3B3B3')
|
||||
Row({ space: 10 }) {
|
||||
TextInput({ text: this.emailCode, placeholder: '6 位验证码' })
|
||||
.height(48)
|
||||
.fontSize(15)
|
||||
.type(InputType.Number)
|
||||
.maxLength(EMAIL_CODE_LENGTH)
|
||||
.borderRadius(12)
|
||||
.backgroundColor('#F7F5F6')
|
||||
.padding({ left: 14, right: 14 })
|
||||
.layoutWeight(1)
|
||||
.onChange((value: string) => {
|
||||
this.emailCode = value.replace(/[^0-9]/g, '').slice(0, EMAIL_CODE_LENGTH)
|
||||
})
|
||||
|
||||
Button(this.sendCooldown > 0 ? `${this.sendCooldown}s 后重发` : (this.sendingCode ? '发送中…' : '发送验证码'))
|
||||
.fontSize(14)
|
||||
.height(48)
|
||||
.borderRadius(12)
|
||||
.backgroundColor(this.sendCooldown > 0 ? '#F0E0E6' : COLOR_PRIMARY)
|
||||
.fontColor(this.sendCooldown > 0 ? '#B3B3B3' : '#FFFFFF')
|
||||
.enabled(!this.sendingCode && this.sendCooldown <= 0)
|
||||
.onClick(() => this.doSendCode())
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Button(this.emailLogging ? '正在登录…' : '登录')
|
||||
.width('100%')
|
||||
.height(50)
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.borderRadius(25)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.fontColor('#FFFFFF')
|
||||
.enabled(!this.emailLogging && isEmail(this.emailInput.trim()) && this.emailCode.trim().length === EMAIL_CODE_LENGTH)
|
||||
.onClick(() => this.doEmailLogin())
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(16)
|
||||
.backgroundColor('#FFFFFF')
|
||||
|
||||
// 高级设置(服务器地址,真机连开发机靠它)
|
||||
// 高级设置(开发者):dev-token 内测登录 + 服务器地址,默认收起
|
||||
Column({ space: 10 }) {
|
||||
Row() {
|
||||
Text('高级设置 · 服务器地址')
|
||||
Text('高级设置(开发者)')
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#2B2B2B')
|
||||
@@ -244,11 +424,54 @@ struct LoginPage {
|
||||
|
||||
if (this.showAdvanced) {
|
||||
Column({ space: 8 }) {
|
||||
TextInput({ text: this.baseUrlInput, placeholder: DEFAULT_BASE_URL })
|
||||
.height(46)
|
||||
// dev-token 内测登录(本机联调通道)
|
||||
Row({ space: 8 }) {
|
||||
Text('内测账号标识')
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#2B2B2B')
|
||||
Blank()
|
||||
Text('随机生成')
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_PRIMARY)
|
||||
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor('#F7E3EA')
|
||||
.onClick(() => this.regenDeviceKey())
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
TextInput({ text: this.deviceKey, placeholder: 'dev-xxxxxxxxxxxxxxxx' })
|
||||
.height(44)
|
||||
.fontSize(14)
|
||||
.borderRadius(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.backgroundColor('#F7F5F6')
|
||||
.padding({ left: 14, right: 14 })
|
||||
.onChange((value: string) => {
|
||||
this.deviceKey = value
|
||||
})
|
||||
|
||||
Button(this.devLogging ? '正在进入…' : '内测通道进入')
|
||||
.width('100%')
|
||||
.height(42)
|
||||
.fontSize(15)
|
||||
.borderRadius(21)
|
||||
.backgroundColor('#F0E0E6')
|
||||
.fontColor(COLOR_PRIMARY)
|
||||
.enabled(!this.devLogging && this.deviceKey.trim() !== '')
|
||||
.onClick(() => this.doDevLogin())
|
||||
|
||||
Divider()
|
||||
.width('100%')
|
||||
.strokeWidth(1)
|
||||
.color('#E5E7EB')
|
||||
.margin({ top: 4, bottom: 4 })
|
||||
|
||||
TextInput({ text: this.baseUrlInput, placeholder: DEFAULT_BASE_URL })
|
||||
.height(44)
|
||||
.fontSize(14)
|
||||
.borderRadius(12)
|
||||
.backgroundColor('#F7F5F6')
|
||||
.padding({ left: 14, right: 14 })
|
||||
.onChange((value: string) => {
|
||||
this.baseUrlInput = value
|
||||
@@ -281,7 +504,7 @@ struct LoginPage {
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text('内测版需连接指定网络;如无法登录,请联系测试人员')
|
||||
Text('内测通道仅供开发联调;如无法登录,请联系测试人员')
|
||||
.fontSize(11)
|
||||
.fontColor('#B3B3B3')
|
||||
}
|
||||
@@ -294,19 +517,7 @@ struct LoginPage {
|
||||
.borderRadius(16)
|
||||
.backgroundColor('#FFFFFF')
|
||||
|
||||
// 登录按钮
|
||||
Button(this.logging ? '正在进入…' : '进入美搭')
|
||||
.width('100%')
|
||||
.height(50)
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.borderRadius(25)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.fontColor('#FFFFFF')
|
||||
.enabled(!this.logging && this.deviceKey.trim() !== '')
|
||||
.onClick(() => this.doLogin())
|
||||
|
||||
// 错误横幅(登录/地址保存失败)
|
||||
// 错误横幅(登录/发送验证码/地址保存失败)
|
||||
ErrorBanner({
|
||||
message: this.errMessage,
|
||||
hint: this.errHint,
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
* 身高/体重为滚轮选择器(showTextPickerDialog),不提供键盘输入
|
||||
* 3) POST /api/v1/reports(request_key 随机 hex;任务 FAILED 后重试换新 key,
|
||||
* 服务端幂等重放不过滤状态,沿用旧 key 只会重放回原失败任务)
|
||||
* 4) 40920(本期已生成)→ 页内对话框:可跳最近一次报告(AppStorage 记录,
|
||||
* 无记录时实时拉报告列表兜底,重启后也找得回)
|
||||
* 4) 40920(本期已生成)→ 页内对话框兜底:后端已不再抛 40920(重复生成改由
|
||||
* report_basic 配额兜底),此处仅为兼容旧端的兜底分支,可跳最近一次报告
|
||||
* (AppStorage 记录,无记录时实时拉报告列表兜底,重启后也找得回)
|
||||
* 5) 成功 → 3 秒轮询任务(TaskPollingPanel,页面销毁自动清定时器,轮询期间允许返回)
|
||||
* 6) SUCCEEDED → router.replaceUrl 结果页(params 带 reportId);FAILED → 失败态 + 新 key 重试
|
||||
* 7) 提交前弹配额确认框(report_basic 终身 1 次,产品方案设计纪律 3「禁止静默扣减」)
|
||||
* 7) 提交前弹配额确认框(report_basic 配额,产品方案设计纪律 3「禁止静默扣减」)
|
||||
* 8) 表单字段做 AppStorage 草稿(f3_report_*):aboutToAppear 恢复、变更即写回,
|
||||
* 提交成功不清(用户可能再生成);photoUri 不持久化(媒体 URI 会失效)
|
||||
*/
|
||||
|
||||
import { router, componentUtils } from '@kit.ArkUI'
|
||||
@@ -36,6 +39,14 @@ const GENDER_OPTIONS: string[] = ['女', '男', '不便告知']
|
||||
const GOAL_OPTIONS: string[] = ['显高', '显瘦', '显白', '显气质', '遮肉', '修饰肩颈']
|
||||
const STYLE_OPTIONS: string[] = ['戏剧', '睿智', '自然', '古典', '优雅', '浪漫', '前卫', '少女', '少年']
|
||||
const LAST_REPORT_STORAGE_KEY: string = 'f3_last_report_id'
|
||||
// 本页表单草稿(f3_ 前缀,与 DiagnosisFormPage 同套风格):跨页面保留已填内容,
|
||||
// 提交成功不清(用户可能再生成);photoUri 不持久化(媒体 URI 会失效)。
|
||||
const DRAFT_GENDER_KEY: string = 'f3_report_gender'
|
||||
const DRAFT_HEIGHT_KEY: string = 'f3_report_height'
|
||||
const DRAFT_WEIGHT_KEY: string = 'f3_report_weight'
|
||||
const DRAFT_BIRTHDAY_KEY: string = 'f3_report_birthday'
|
||||
const DRAFT_GOALS_KEY: string = 'f3_report_goals' // 多选用 '|' 拼接成串存储
|
||||
const DRAFT_STYLE_KEY: string = 'f3_report_style'
|
||||
|
||||
// 身高/体重改为选择器(不允许键盘输入):数值范围与原校验一致
|
||||
const HEIGHT_MIN: number = 100
|
||||
@@ -112,28 +123,79 @@ struct ReportCreatePage {
|
||||
if (cached !== undefined && cached !== '') {
|
||||
this.lastReportId = cached
|
||||
}
|
||||
// 美学诊断填写页(DiagnosisFormPage)预填进入(需求 v2 §3.6):只填四字段,
|
||||
// 核心诉求/心理风格仍由用户在本页补齐;无参数时行为与旧直达完全一致。
|
||||
// 先恢复本页草稿(跨页面保留已填内容),再应用路由参数:
|
||||
// 美学诊断填写页(DiagnosisFormPage)预填进入(需求 v2 §3.6)时参数优先,
|
||||
// 并同步更新草稿;无参数时行为与旧直达完全一致。
|
||||
this.restoreDraft()
|
||||
const params = router.getParams() as Record<string, Object> | undefined
|
||||
if (params !== undefined) {
|
||||
let paramsApplied = false
|
||||
if (typeof params['gender'] === 'string' && params['gender'].length > 0) {
|
||||
this.gender = params['gender'] as string
|
||||
paramsApplied = true
|
||||
}
|
||||
if (typeof params['birthday'] === 'string' && params['birthday'].length >= 8) {
|
||||
const d = new Date(params['birthday'] as string)
|
||||
if (!isNaN(d.getTime())) {
|
||||
this.birthday = d
|
||||
paramsApplied = true
|
||||
}
|
||||
}
|
||||
if (typeof params['height_cm'] === 'string' && (params['height_cm'] as string).length > 0) {
|
||||
this.heightInput = params['height_cm'] as string
|
||||
paramsApplied = true
|
||||
}
|
||||
if (typeof params['weight_kg'] === 'string' && (params['weight_kg'] as string).length > 0) {
|
||||
this.weightInput = params['weight_kg'] as string
|
||||
paramsApplied = true
|
||||
}
|
||||
if (paramsApplied) {
|
||||
this.saveDraft()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 恢复草稿(photoUri 不在草稿内:媒体 URI 会失效,每次需重选)。 */
|
||||
private restoreDraft(): void {
|
||||
const gender = AppStorage.get<string>(DRAFT_GENDER_KEY)
|
||||
if (gender !== undefined) {
|
||||
this.gender = gender
|
||||
}
|
||||
const height = AppStorage.get<string>(DRAFT_HEIGHT_KEY)
|
||||
if (height !== undefined) {
|
||||
this.heightInput = height
|
||||
}
|
||||
const weight = AppStorage.get<string>(DRAFT_WEIGHT_KEY)
|
||||
if (weight !== undefined) {
|
||||
this.weightInput = weight
|
||||
}
|
||||
const birthday = AppStorage.get<string>(DRAFT_BIRTHDAY_KEY)
|
||||
if (birthday !== undefined && birthday.length >= 8) {
|
||||
const d = new Date(birthday)
|
||||
if (!isNaN(d.getTime())) {
|
||||
this.birthday = d
|
||||
}
|
||||
}
|
||||
const goals = AppStorage.get<string>(DRAFT_GOALS_KEY)
|
||||
if (goals !== undefined && goals !== '') {
|
||||
this.coreGoals = goals.split('|')
|
||||
}
|
||||
const style = AppStorage.get<string>(DRAFT_STYLE_KEY)
|
||||
if (style !== undefined) {
|
||||
this.psychStyle = style
|
||||
}
|
||||
}
|
||||
|
||||
/** 草稿写回(write-through):每次字段变更即落 AppStorage;提交成功不清。 */
|
||||
private saveDraft(): void {
|
||||
AppStorage.setOrCreate<string>(DRAFT_GENDER_KEY, this.gender)
|
||||
AppStorage.setOrCreate<string>(DRAFT_HEIGHT_KEY, this.heightInput)
|
||||
AppStorage.setOrCreate<string>(DRAFT_WEIGHT_KEY, this.weightInput)
|
||||
AppStorage.setOrCreate<string>(DRAFT_BIRTHDAY_KEY, formatDate(this.birthday))
|
||||
AppStorage.setOrCreate<string>(DRAFT_GOALS_KEY, this.coreGoals.join('|'))
|
||||
AppStorage.setOrCreate<string>(DRAFT_STYLE_KEY, this.psychStyle)
|
||||
}
|
||||
|
||||
private validateForm(): boolean {
|
||||
let ok = true
|
||||
this.photoErr = this.photoUri === '' ? '请上传个人照片' : ''
|
||||
@@ -172,6 +234,7 @@ struct ReportCreatePage {
|
||||
this.coreGoals.push(g)
|
||||
}
|
||||
this.goalsErr = ''
|
||||
this.saveDraft()
|
||||
}
|
||||
|
||||
private isSelectedGoal(g: string): boolean {
|
||||
@@ -196,6 +259,7 @@ struct ReportCreatePage {
|
||||
if (idx >= 0 && idx < HEIGHT_OPTIONS.length) {
|
||||
this.heightInput = HEIGHT_OPTIONS[idx]
|
||||
this.heightErr = ''
|
||||
this.saveDraft()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -213,6 +277,7 @@ struct ReportCreatePage {
|
||||
if (idx >= 0 && idx < WEIGHT_OPTIONS.length) {
|
||||
this.weightInput = WEIGHT_OPTIONS[idx]
|
||||
this.weightErr = ''
|
||||
this.saveDraft()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -273,7 +338,7 @@ struct ReportCreatePage {
|
||||
this.openQuotaConfirm()
|
||||
}
|
||||
|
||||
/** 打开配额确认框并拉取当前剩余(首份报告为终身 1 次权益)。 */
|
||||
/** 打开配额确认框并拉取当前剩余(report_basic 终身配额)。 */
|
||||
private openQuotaConfirm(): void {
|
||||
this.showQuotaConfirm = true
|
||||
this.quotaLoaded = false
|
||||
@@ -403,7 +468,8 @@ struct ReportCreatePage {
|
||||
this.submitting = false
|
||||
if (e instanceof ApiError) {
|
||||
if (e.code === 40920) {
|
||||
// 本期报告已生成:始终给「查看我的报告」入口(无本地记录也能从服务端找回)
|
||||
// 兜底分支:后端已不再抛 40920(重复生成由 report_basic 配额兜底),
|
||||
// 仅兼容旧端;始终给「查看我的报告」入口(无本地记录也能从服务端找回)
|
||||
this.showDupDialog = true
|
||||
return
|
||||
}
|
||||
@@ -538,7 +604,7 @@ struct ReportCreatePage {
|
||||
.fontColor('#999999')
|
||||
}
|
||||
.width('100%')
|
||||
.height(140)
|
||||
.aspectRatio(0.75) // 竖版 3:4:与全身照取景一致,不再用横条占位
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.backgroundColor('#F5F6F8')
|
||||
.borderRadius(10)
|
||||
@@ -550,7 +616,7 @@ struct ReportCreatePage {
|
||||
Stack() {
|
||||
Image(this.photoUri)
|
||||
.width('100%')
|
||||
.height(220)
|
||||
.aspectRatio(0.75) // 竖版 3:4:竖屏全身照不再被上下截断
|
||||
.objectFit(ImageFit.Cover)
|
||||
.borderRadius(10)
|
||||
Text('重选')
|
||||
@@ -598,6 +664,7 @@ struct ReportCreatePage {
|
||||
.onClick(() => {
|
||||
this.gender = g
|
||||
this.genderErr = ''
|
||||
this.saveDraft()
|
||||
})
|
||||
}, (g: string) => g)
|
||||
}
|
||||
@@ -682,6 +749,7 @@ struct ReportCreatePage {
|
||||
})
|
||||
.onDateChange((d: Date) => {
|
||||
this.birthday = d
|
||||
this.saveDraft()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -734,6 +802,7 @@ struct ReportCreatePage {
|
||||
.onClick(() => {
|
||||
this.psychStyle = s
|
||||
this.styleErr = ''
|
||||
this.saveDraft()
|
||||
})
|
||||
}, (s: string) => s)
|
||||
}
|
||||
@@ -879,15 +948,15 @@ struct ReportCreatePage {
|
||||
@Builder
|
||||
DupDialog() {
|
||||
Column({ space: 14 }) {
|
||||
Text('本期报告已生成')
|
||||
Text('已有生成过的报告')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text('首份形象报告为一次性权益,已生成的报告可随时查看。')
|
||||
Text('可直接查看最近一次的报告,也可返回修改后重新提交生成新报告。')
|
||||
.fontSize(13)
|
||||
.fontColor('#666666')
|
||||
.textAlign(TextAlign.Center)
|
||||
Text('将为你打开最近一次生成的报告。')
|
||||
Text('选择「查看我的报告」将为你打开最近一次生成的报告。')
|
||||
.fontSize(12)
|
||||
.fontColor('#999999')
|
||||
Row({ space: 12 }) {
|
||||
@@ -918,7 +987,7 @@ struct ReportCreatePage {
|
||||
.backgroundColor('#FFFFFF')
|
||||
}
|
||||
|
||||
/** 提交前配额确认框(#5:终身 1 次权益禁止静默扣减)。 */
|
||||
/** 提交前配额确认框(#5:消耗额度禁止静默扣减)。 */
|
||||
@Builder
|
||||
QuotaConfirmDialog() {
|
||||
Column({ space: 14 }) {
|
||||
@@ -926,7 +995,7 @@ struct ReportCreatePage {
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text('首份形象报告为一次性权益,本次生成将使用 1 次。')
|
||||
Text('生成报告将消耗 1 次报告额度。')
|
||||
.fontSize(13)
|
||||
.fontColor('#666666')
|
||||
.textAlign(TextAlign.Center)
|
||||
|
||||
@@ -16,6 +16,17 @@
|
||||
* (服务端幂等重放不过滤状态,同 key 只会重放回原失败任务)
|
||||
* - 导出 PDF / 重新生成均为消耗配额的操作:提交前弹确认框展示本次代价
|
||||
* 与剩余次数(产品方案设计纪律 3「禁止静默扣减」)
|
||||
* - 重新生成遇 40900(早期种子报告无 rp_input 输入行)弹对话框引导去生成
|
||||
* 新报告,其它错误维持底部红字;底部另有「查看历史报告」次级入口
|
||||
* (pushUrl HomePage + initialTab=1 落地「报告」Tab)
|
||||
* - 深度诊断(tier=full):basic 报告页底部的入口卡——未生成显示内容清单 +
|
||||
* 「生成深度诊断报告」(配额确认框 → POST /reports/{id}/full → 同页轮询 →
|
||||
* 采纳新 report_id 重拉,幂等键 FAILED 换新,模式同 doRegenerate);
|
||||
* 已生成显示「查看深度诊断报告」(进页时 GET /reports?limit=20 找最新
|
||||
* tier=full 且 SUCCEEDED 的一份);当前页本身是 full 报告时卡片换成
|
||||
* 「查看基础版报告」,防止 full→full 套娃
|
||||
* - full 报告增量渲染:result.full 存在时在 32 维诊断区之后渲染十二季色板
|
||||
* (色块 chips)、4 场景穿搭(分组卡)、30 天计划(按周)三个区块
|
||||
*/
|
||||
|
||||
import { router, promptAction } from '@kit.ArkUI'
|
||||
@@ -35,8 +46,14 @@ import { LoadingView } from '../components/f2/LoadingView'
|
||||
import {
|
||||
CopySectionWire,
|
||||
DimensionWire,
|
||||
FullOutfitWire,
|
||||
FullResultWire,
|
||||
FullSceneWire,
|
||||
PaletteColorWire,
|
||||
PlanWeekWire,
|
||||
ReportCreateResp,
|
||||
ReportDetailWire,
|
||||
ReportListItemWire,
|
||||
ReportRouteParams,
|
||||
formatIsoTime,
|
||||
randomHex,
|
||||
@@ -81,22 +98,16 @@ interface ClaimWire {
|
||||
remaining: number
|
||||
}
|
||||
|
||||
/** POST /reservations 响应(T-P3-22)。created=false 表示幂等命中已有记录。 */
|
||||
interface ReservationWire {
|
||||
topic: string
|
||||
created: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** GET /reservations?topic=… 响应。 */
|
||||
interface ReservationListWire {
|
||||
topics: string[]
|
||||
}
|
||||
|
||||
/** 本周期已领(与后端 errors.py 对齐)。 */
|
||||
const CODE_ALREADY_CLAIMED: number = 40910
|
||||
/** 预约主题(T-P3-22 首期唯一值)。 */
|
||||
const RESERVE_TOPIC: string = 'full_report'
|
||||
/** 状态冲突(与后端 errors.py 对齐):重新生成时输入缺失的早期种子报告走此码。 */
|
||||
const CODE_STATE_CONFLICT: number = 40900
|
||||
|
||||
/** PaletteGroup @Builder 的引用传递参数($$ 形态,数组重赋值才能刷新)。 */
|
||||
interface PaletteGroupParams {
|
||||
title: string
|
||||
colors: PaletteColorWire[]
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
@@ -119,6 +130,7 @@ struct ReportResultPage {
|
||||
@State regenerating: boolean = false
|
||||
@State regenTaskId: string = ''
|
||||
@State regenError: string = ''
|
||||
@State showRegenMissingDialog: boolean = false // 40900:早期种子报告无 rp_input,引导去生成新报告
|
||||
private regenRequestKey: string = '' // 任务 FAILED 后置空:重试换新 key(同 key 只会重放回失败任务)
|
||||
@State exporting: boolean = false
|
||||
@State exportTaskId: string = ''
|
||||
@@ -131,9 +143,22 @@ struct ReportResultPage {
|
||||
// —— 结论反馈(T-P3-21):dimension_key → verdict 回显表 ——
|
||||
@State feedbackVerdicts: Record<string, string> = {}
|
||||
@State feedbackSubmitting: string = '' // 提交中的 dimension_key(防空点)
|
||||
// —— 全面报告预约留资卡(T-P3-22)——
|
||||
@State reserved: boolean = false
|
||||
@State reserveLoading: boolean = false
|
||||
// —— 深度诊断(tier=full):入口卡 + 增量渲染 ——
|
||||
@State tier: string = 'basic'
|
||||
@State fullReportId: string = '' // 已有 SUCCEEDED 的 full 报告(进页时列表查出)
|
||||
@State basicReportId: string = '' // full 报告页「查看基础版报告」的目标
|
||||
@State fullGenerating: boolean = false
|
||||
@State fullTaskId: string = ''
|
||||
@State fullError: string = ''
|
||||
private fullRequestKey: string = '' // 任务 FAILED 后置空:重试换新 key
|
||||
@State hasFull: boolean = false
|
||||
@State fullSeasonName: string = ''
|
||||
@State fullSeasonAnalysis: string = ''
|
||||
@State paletteBest: PaletteColorWire[] = []
|
||||
@State paletteNeutral: PaletteColorWire[] = []
|
||||
@State paletteAvoid: PaletteColorWire[] = []
|
||||
@State fullScenes: FullSceneWire[] = []
|
||||
@State fullPlan: PlanWeekWire[] = []
|
||||
private cardViewTracked: boolean = false // full_report.card_view 每进入结果页只打一次
|
||||
|
||||
aboutToAppear(): void {
|
||||
@@ -159,6 +184,7 @@ struct ReportResultPage {
|
||||
this.loadErrorAuth = false
|
||||
try {
|
||||
const d = await ApiClient.get<ReportDetailWire>(`/api/v1/reports/${this.reportId}`)
|
||||
this.tier = d.tier ?? 'basic'
|
||||
this.versionLabel = d.version !== undefined && d.version !== null ? `v${d.version}` : 'v1'
|
||||
// 后端报告状态为小写(succeeded/failed/…),统一转大写供下方状态门禁比较
|
||||
this.statusRaw = (d.status ?? '').toUpperCase()
|
||||
@@ -177,6 +203,16 @@ struct ReportResultPage {
|
||||
this.totalLabel = total !== undefined && total !== null ? `${total}` : `${dims.length}`
|
||||
const basicFull = result?.diagnosis?.coverage?.basic_full
|
||||
this.basicFullText = basicFull === true ? '基础信息完整' : (basicFull === false ? '基础信息不完整' : '')
|
||||
// 深度诊断增量内容(tier=full 才有;basic 报告恒空,区块不渲染)
|
||||
const full: FullResultWire | undefined = result?.full
|
||||
this.hasFull = full !== undefined && full !== null
|
||||
this.fullSeasonName = full?.season?.name_cn ?? ''
|
||||
this.fullSeasonAnalysis = full?.season?.analysis ?? ''
|
||||
this.paletteBest = full?.palette?.best ?? []
|
||||
this.paletteNeutral = full?.palette?.neutral ?? []
|
||||
this.paletteAvoid = full?.palette?.avoid ?? []
|
||||
this.fullScenes = full?.scenes ?? []
|
||||
this.fullPlan = full?.plan30 ?? []
|
||||
const rid = d.report_id
|
||||
if (rid !== undefined && rid !== '') {
|
||||
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, rid)
|
||||
@@ -186,18 +222,19 @@ struct ReportResultPage {
|
||||
// 一并换新,防止新报告复用旧导出键撞后端 IDEMPOTENCY_CONFLICT(#22)
|
||||
this.regenRequestKey = ''
|
||||
this.exportRequestKey = ''
|
||||
this.fullRequestKey = ''
|
||||
if (this.statusRaw === 'SUCCEEDED') {
|
||||
// 反馈回显 + report_feedback 任务闭环的数据源(T-P3-21)
|
||||
this.loadFeedback()
|
||||
// first_report 任务闭环(T2b):报告成功展示的入口路径上静默 claim
|
||||
// (lifetime 周期,后端幂等;领过则 40910 静默同步已领态)
|
||||
this.claimFirstReport()
|
||||
// 预约留资卡(T-P3-22):卡片渲染即曝光埋点,每进入结果页只打一次
|
||||
// 深度诊断入口卡:卡片渲染即曝光埋点,每进入结果页只打一次
|
||||
if (!this.cardViewTracked) {
|
||||
this.cardViewTracked = true
|
||||
Tracker.track('full_report.card_view')
|
||||
}
|
||||
this.loadReservation()
|
||||
this.loadFullReportList()
|
||||
}
|
||||
} catch (e) {
|
||||
this.loading = false
|
||||
@@ -237,6 +274,12 @@ struct ReportResultPage {
|
||||
this.regenerating = true // 同页复用轮询(TaskPollingPanel),SUCCEEDED 后自动重拉
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.code === CODE_STATE_CONFLICT) {
|
||||
// 40900:早期种子报告没有 rp_input 输入行,无法复用照片/问卷直接重新生成。
|
||||
// 一行红字感知太弱(像"点了没反应"),改弹对话框引导去生成新报告。
|
||||
this.showRegenMissingDialog = true
|
||||
return
|
||||
}
|
||||
this.regenError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message
|
||||
} else {
|
||||
this.regenError = '请求失败,请检查网络'
|
||||
@@ -336,9 +379,9 @@ struct ReportResultPage {
|
||||
this.exportRequestKey = ''
|
||||
}
|
||||
|
||||
/** 打开配额确认框(regen=report_regen 每 30 天 1 次 / export=hd_export 每日 1 次)。 */
|
||||
/** 打开配额确认框(regen=report_regen 每 30 天 1 次 / export=hd_export 每日 1 次 / full=full_report 每日 1 次)。 */
|
||||
private openConfirm(action: string): void {
|
||||
if (action !== 'regen' && action !== 'export') {
|
||||
if (action !== 'regen' && action !== 'export' && action !== 'full') {
|
||||
return
|
||||
}
|
||||
if (action === 'regen' && (this.regenerating || this.reportId === '')) {
|
||||
@@ -347,6 +390,9 @@ struct ReportResultPage {
|
||||
if (action === 'export' && (this.exporting || this.reportId === '')) {
|
||||
return
|
||||
}
|
||||
if (action === 'full' && (this.fullGenerating || this.reportId === '')) {
|
||||
return
|
||||
}
|
||||
this.confirmAction = action
|
||||
this.confirmLoaded = false
|
||||
this.confirmRemaining = -1
|
||||
@@ -355,7 +401,9 @@ struct ReportResultPage {
|
||||
}
|
||||
|
||||
private async loadConfirmQuota(): Promise<void> {
|
||||
const key = this.confirmAction === 'export' ? 'hd_export' : 'report_regen'
|
||||
const key = this.confirmAction === 'export'
|
||||
? 'hd_export'
|
||||
: (this.confirmAction === 'full' ? 'full_report' : 'report_regen')
|
||||
try {
|
||||
const d = await ApiClient.get<QuotaWireResp>(`/api/v1/quota?quota_key=${key}`)
|
||||
this.confirmRemaining = d.remaining
|
||||
@@ -375,6 +423,8 @@ struct ReportResultPage {
|
||||
this.doRegenerate()
|
||||
} else if (action === 'export') {
|
||||
this.doExport()
|
||||
} else if (action === 'full') {
|
||||
this.doFullGenerate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,39 +519,87 @@ struct ReportResultPage {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 全面报告预约留资卡(T-P3-22) ====================
|
||||
// ==================== 深度诊断(tier=full)入口卡 ====================
|
||||
|
||||
/** 拉预约态决定按钮;失败保持未预约态(用户可点击,服务端幂等兜底)。 */
|
||||
private async loadReservation(): Promise<void> {
|
||||
/**
|
||||
* 查报告列表(limit=20 对齐首页报告 Tab):找最新一份 tier=full 且
|
||||
* SUCCEEDED 的报告(已生成态 → 「查看深度诊断报告」),同时记下最新
|
||||
* basic 报告 id(full 报告页「查看基础版报告」的跳转目标)。
|
||||
* 失败静默:卡片按未生成态展示,生成按钮仍可用(服务端幂等兜底)。
|
||||
*/
|
||||
private async loadFullReportList(): Promise<void> {
|
||||
try {
|
||||
const d = await ApiClient.get<ReservationListWire>(`/api/v1/reservations?topic=${RESERVE_TOPIC}`)
|
||||
this.reserved = d.topics.includes(RESERVE_TOPIC)
|
||||
const list = await ApiClient.get<ReportListItemWire[]>('/api/v1/reports?limit=20')
|
||||
let fullId: string = ''
|
||||
let basicId: string = ''
|
||||
for (const item of list) {
|
||||
const ok = (item.status ?? '').toUpperCase() === 'SUCCEEDED'
|
||||
const rid = item.report_id ?? ''
|
||||
if (!ok || rid === '') {
|
||||
continue
|
||||
}
|
||||
if (fullId === '' && item.tier === 'full') {
|
||||
fullId = rid
|
||||
}
|
||||
if (basicId === '' && item.tier !== 'full') {
|
||||
basicId = rid
|
||||
}
|
||||
}
|
||||
this.fullReportId = fullId
|
||||
this.basicReportId = basicId
|
||||
} catch (_e) {
|
||||
this.reserved = false
|
||||
this.fullReportId = ''
|
||||
this.basicReportId = ''
|
||||
}
|
||||
}
|
||||
|
||||
private async doReserve(): Promise<void> {
|
||||
if (this.reserved || this.reserveLoading) {
|
||||
/** 生成深度诊断:模式同 doRegenerate(新 report_id 必须采纳,轮询成功本页重拉)。 */
|
||||
private async doFullGenerate(): Promise<void> {
|
||||
if (this.fullGenerating || this.reportId === '') {
|
||||
return
|
||||
}
|
||||
this.reserveLoading = true
|
||||
try {
|
||||
const req: Record<string, Object> = { 'topic': RESERVE_TOPIC }
|
||||
const d = await ApiClient.post<ReservationWire>('/api/v1/reservations', req)
|
||||
this.reserved = true // created=false(幂等命中)同样是已预约态
|
||||
Tracker.track('full_report.reserve_result', { 'ok': true })
|
||||
if (d.created) {
|
||||
this.toast('预约成功,上线后第一时间告诉你')
|
||||
}
|
||||
} catch (e) {
|
||||
Tracker.track('full_report.reserve_result', {
|
||||
'ok': false, 'code': e instanceof ApiError ? e.code : 50000
|
||||
})
|
||||
this.toast(e instanceof ApiError ? e.message : '预约失败,请稍后重试')
|
||||
} finally {
|
||||
this.reserveLoading = false
|
||||
this.fullError = ''
|
||||
if (this.fullRequestKey === '') {
|
||||
this.fullRequestKey = randomHex(16)
|
||||
}
|
||||
const req: Record<string, Object> = { 'request_key': this.fullRequestKey }
|
||||
try {
|
||||
const data = await ApiClient.post<ReportCreateResp>(`/api/v1/reports/${this.reportId}/full`, req)
|
||||
const tid = data.task_id ?? ''
|
||||
if (tid === '') {
|
||||
this.fullError = '服务端未返回任务 ID,请稍后重试'
|
||||
return
|
||||
}
|
||||
// 深度诊断在后端是新建一行报告(tier=full、新 report_id):必须采纳新 id,
|
||||
// 否则轮询成功后 loadReport 拉回的仍是基础版报告
|
||||
const newRid = data.report_id ?? ''
|
||||
if (newRid !== '') {
|
||||
this.reportId = newRid
|
||||
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, newRid)
|
||||
}
|
||||
this.fullTaskId = tid
|
||||
this.fullGenerating = true
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
this.fullError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message
|
||||
} else {
|
||||
this.fullError = '请求失败,请检查网络'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onFullSucceeded(): void {
|
||||
this.fullGenerating = false
|
||||
this.fullError = ''
|
||||
this.fullReportId = this.reportId // 本页已是 full 报告
|
||||
this.loadReport()
|
||||
}
|
||||
|
||||
private onFullFailed(reason: string): void {
|
||||
this.fullGenerating = false
|
||||
this.fullError = `深度诊断生成失败:${reason}`
|
||||
// 清空幂等键:重试以新 key 创建新任务(旧失败任务的额度已由服务端退还)
|
||||
this.fullRequestKey = ''
|
||||
}
|
||||
|
||||
private dimKey(d: DimensionWire): string {
|
||||
@@ -588,24 +686,50 @@ struct ReportResultPage {
|
||||
.padding({ left: 24, right: 24 })
|
||||
}
|
||||
|
||||
private confirmTitle(): string {
|
||||
if (this.confirmAction === 'export') {
|
||||
return '确认导出 PDF'
|
||||
}
|
||||
if (this.confirmAction === 'full') {
|
||||
return '确认生成深度诊断'
|
||||
}
|
||||
return '确认重新生成'
|
||||
}
|
||||
|
||||
private confirmDesc(): string {
|
||||
if (this.confirmAction === 'export') {
|
||||
return '导出 PDF 每日可使用 1 次,本次将使用 1 次。'
|
||||
}
|
||||
if (this.confirmAction === 'full') {
|
||||
return '深度诊断每日可生成 1 次,本次将使用 1 次。'
|
||||
}
|
||||
return '重新生成每 30 天可使用 1 次,本次将使用 1 次。'
|
||||
}
|
||||
|
||||
private confirmRemainText(): string {
|
||||
if (this.confirmAction === 'export') {
|
||||
return `今日还可导出 ${this.confirmRemaining} 次`
|
||||
}
|
||||
if (this.confirmAction === 'full') {
|
||||
return `今日还可生成 ${this.confirmRemaining} 次`
|
||||
}
|
||||
return `本周期还可重新生成 ${this.confirmRemaining} 次`
|
||||
}
|
||||
|
||||
/** 消耗配额前的确认框(#5):展示本次代价与剩余次数,剩余 0 时阻止提交。 */
|
||||
@Builder
|
||||
QuotaConfirmDialog() {
|
||||
Column({ space: 14 }) {
|
||||
Text(this.confirmAction === 'export' ? '确认导出 PDF' : '确认重新生成')
|
||||
Text(this.confirmTitle())
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text(this.confirmAction === 'export'
|
||||
? '导出 PDF 每日可使用 1 次,本次将使用 1 次。'
|
||||
: '重新生成每 30 天可使用 1 次,本次将使用 1 次。')
|
||||
Text(this.confirmDesc())
|
||||
.fontSize(13)
|
||||
.fontColor('#666666')
|
||||
.textAlign(TextAlign.Center)
|
||||
if (this.confirmLoaded && this.confirmRemaining > 0) {
|
||||
Text(this.confirmAction === 'export'
|
||||
? `今日还可导出 ${this.confirmRemaining} 次`
|
||||
: `本周期还可重新生成 ${this.confirmRemaining} 次`)
|
||||
Text(this.confirmRemainText())
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_PRIMARY)
|
||||
} else if (this.confirmLoaded && this.confirmRemaining <= 0) {
|
||||
@@ -645,12 +769,53 @@ struct ReportResultPage {
|
||||
.backgroundColor('#FFFFFF')
|
||||
}
|
||||
|
||||
/** 40900 兜底引导:早期种子报告缺输入行,无法直接重新生成 → 引导去生成新报告。 */
|
||||
@Builder
|
||||
RegenMissingDialog() {
|
||||
Column({ space: 14 }) {
|
||||
Text('无法直接重新生成')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text('该报告为早期版本,缺少可复用的照片与问卷信息。可以重新拍摄照片,生成一份全新报告。')
|
||||
.fontSize(13)
|
||||
.fontColor('#666666')
|
||||
.textAlign(TextAlign.Center)
|
||||
Row({ space: 12 }) {
|
||||
Button('取消')
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor('#F3F4F6')
|
||||
.fontColor('#666666')
|
||||
.onClick(() => {
|
||||
this.showRegenMissingDialog = false
|
||||
})
|
||||
Button('去生成新报告')
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.showRegenMissingDialog = false
|
||||
router.pushUrl({ url: 'pages/ReportCreatePage' })
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
.width('82%')
|
||||
.padding(20)
|
||||
.borderRadius(16)
|
||||
.backgroundColor('#FFFFFF')
|
||||
}
|
||||
|
||||
@Builder
|
||||
HeaderCard() {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Column({ space: 2 }) {
|
||||
Text('形象诊断报告')
|
||||
Text(this.tier === 'full' ? '形象深度诊断报告' : '形象诊断报告')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
@@ -824,51 +989,103 @@ struct ReportResultPage {
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
/** 全面报告预约留资卡(T-P3-22):模糊目录 teaser,无价格字样(FR-BILL-07 纪律同 apply)。 */
|
||||
@Builder
|
||||
ReservationCard() {
|
||||
FullGenView() {
|
||||
Column({ space: 16 }) {
|
||||
Text('正在生成深度诊断报告')
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text('基于你的基础报告生成更完整的色彩、场景与计划内容,完成后自动展示')
|
||||
.fontSize(13)
|
||||
.fontColor('#999999')
|
||||
.textAlign(TextAlign.Center)
|
||||
TaskPollingPanel({
|
||||
taskId: this.fullTaskId,
|
||||
onSucceeded: (): void => {
|
||||
this.onFullSucceeded()
|
||||
},
|
||||
onFailed: (reason: string): void => {
|
||||
this.onFullFailed(reason)
|
||||
}
|
||||
})
|
||||
.width('86%')
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ left: 24, right: 24 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度诊断入口卡(内容区底部)。三态:当前页是 full →「查看基础版报告」
|
||||
* (防 full→full 套娃);已有 full →「查看深度诊断报告」(本页内采纳 id
|
||||
* 重拉,不压路由栈);未生成 → 内容清单 +「生成深度诊断报告」(配额确认框)。
|
||||
*/
|
||||
@Builder
|
||||
FullReportCard() {
|
||||
Column({ space: 10 }) {
|
||||
Text('形象深度诊断 · 即将上线')
|
||||
Text(this.tier === 'full' ? '当前为深度诊断报告' : '形象深度诊断')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
.width('100%')
|
||||
Column({ space: 6 }) {
|
||||
Text('· 十二季色彩完整色板')
|
||||
.fontSize(13)
|
||||
.fontColor('#555555')
|
||||
.width('100%')
|
||||
Text('· 4+ 场景穿搭方案')
|
||||
.fontSize(13)
|
||||
.fontColor('#555555')
|
||||
.width('100%')
|
||||
Text('· 30 天形象提升计划')
|
||||
.fontSize(13)
|
||||
.fontColor('#555555')
|
||||
.width('100%')
|
||||
if (this.tier !== 'full' && this.fullReportId === '') {
|
||||
Column({ space: 6 }) {
|
||||
Text('· 十二季色彩完整色板')
|
||||
.fontSize(13)
|
||||
.fontColor('#555555')
|
||||
.width('100%')
|
||||
Text('· 4+ 场景穿搭方案')
|
||||
.fontSize(13)
|
||||
.fontColor('#555555')
|
||||
.width('100%')
|
||||
Text('· 30 天形象提升计划')
|
||||
.fontSize(13)
|
||||
.fontColor('#555555')
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
if (this.reserved) {
|
||||
Text('已预约,上线后第一时间告诉你')
|
||||
.fontSize(14)
|
||||
.fontColor('#9AA0A6')
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(22)
|
||||
.backgroundColor('#F3F4F6')
|
||||
} else {
|
||||
Button(this.reserveLoading ? '预约中…' : '抢先预约,上线提醒我')
|
||||
if (this.tier === 'full') {
|
||||
if (this.basicReportId !== '') {
|
||||
Button('查看基础版报告')
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.fontSize(15)
|
||||
.fontColor(COLOR_PRIMARY)
|
||||
.backgroundColor('#F7E3EA')
|
||||
.borderRadius(22)
|
||||
.onClick(() => {
|
||||
this.reportId = this.basicReportId
|
||||
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, this.basicReportId)
|
||||
this.loadReport()
|
||||
})
|
||||
}
|
||||
} else if (this.fullReportId !== '') {
|
||||
Button('查看深度诊断报告')
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.fontSize(15)
|
||||
.fontColor(Color.White)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.borderRadius(22)
|
||||
.enabled(!this.reserveLoading)
|
||||
.onClick(() => {
|
||||
this.doReserve()
|
||||
this.reportId = this.fullReportId
|
||||
AppStorage.setOrCreate<string>(LAST_REPORT_STORAGE_KEY, this.fullReportId)
|
||||
this.loadReport()
|
||||
})
|
||||
} else {
|
||||
Button(this.fullError !== '' ? '重试生成深度诊断报告' : '生成深度诊断报告')
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.fontSize(15)
|
||||
.fontColor(Color.White)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.borderRadius(22)
|
||||
.onClick(() => {
|
||||
this.openConfirm('full')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -879,6 +1096,154 @@ struct ReportResultPage {
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
// ==================== 深度诊断增量渲染(result.full 存在时) ====================
|
||||
|
||||
@Builder
|
||||
PaletteGroup($$: PaletteGroupParams) {
|
||||
if ($$.colors.length > 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text(`${$$.title}(${$$.colors.length} 色)`)
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#555555')
|
||||
.width('100%')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach($$.colors, (c: PaletteColorWire, idx: number) => {
|
||||
Column({ space: 4 }) {
|
||||
Blank()
|
||||
.width(44)
|
||||
.height(30)
|
||||
.borderRadius(6)
|
||||
.backgroundColor(c.hex ?? '#CCCCCC')
|
||||
.border({ width: 1, color: '#E5E7EB' })
|
||||
Text(c.name ?? '')
|
||||
.fontSize(10)
|
||||
.fontColor('#666666')
|
||||
.maxLines(1)
|
||||
}
|
||||
.width(56)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.margin({ bottom: 8 })
|
||||
}, (c: PaletteColorWire, idx: number) => `${idx}-${c.name ?? ''}`)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
FullSeasonPaletteCard() {
|
||||
Column({ space: 12 }) {
|
||||
Text(this.fullSeasonName !== '' ? `十二季色彩定位 · ${this.fullSeasonName}` : '十二季色彩定位')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
.width('100%')
|
||||
if (this.fullSeasonAnalysis !== '') {
|
||||
Text(this.fullSeasonAnalysis)
|
||||
.fontSize(13)
|
||||
.fontColor('#555555')
|
||||
.lineHeight(21)
|
||||
.width('100%')
|
||||
}
|
||||
this.PaletteGroup({ title: '适合色', colors: this.paletteBest })
|
||||
this.PaletteGroup({ title: '中性色', colors: this.paletteNeutral })
|
||||
this.PaletteGroup({ title: '避雷色', colors: this.paletteAvoid })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
FullScenesCard() {
|
||||
Column({ space: 12 }) {
|
||||
Text('场景穿搭方案')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
.width('100%')
|
||||
ForEach(this.fullScenes, (scene: FullSceneWire, sIdx: number) => {
|
||||
Column({ space: 8 }) {
|
||||
Text(scene.title ?? '')
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#444444')
|
||||
.width('100%')
|
||||
ForEach(scene.outfits ?? [], (outfit: FullOutfitWire, oIdx: number) => {
|
||||
Column({ space: 4 }) {
|
||||
Text(outfit.name ?? '')
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(COLOR_PRIMARY)
|
||||
.width('100%')
|
||||
ForEach(outfit.points ?? [], (point: string, pIdx: number) => {
|
||||
Text(`· ${point}`)
|
||||
.fontSize(12)
|
||||
.fontColor('#555555')
|
||||
.lineHeight(19)
|
||||
.width('100%')
|
||||
}, (point: string, pIdx: number) => `${sIdx}-${oIdx}-${pIdx}`)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(10)
|
||||
.borderRadius(10)
|
||||
.backgroundColor('#F7F8FA')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}, (outfit: FullOutfitWire, oIdx: number) => `${sIdx}-${oIdx}-${outfit.name ?? ''}`)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}, (scene: FullSceneWire, sIdx: number) => `${sIdx}-${scene.key ?? ''}`)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
FullPlanCard() {
|
||||
Column({ space: 12 }) {
|
||||
Text('30 天形象提升计划')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
.width('100%')
|
||||
ForEach(this.fullPlan, (week: PlanWeekWire, wIdx: number) => {
|
||||
Column({ space: 6 }) {
|
||||
Text(week.title ?? `第 ${week.week ?? wIdx + 1} 周`)
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#444444')
|
||||
.width('100%')
|
||||
ForEach(week.tasks ?? [], (task: string, tIdx: number) => {
|
||||
Text(`· ${task}`)
|
||||
.fontSize(12)
|
||||
.fontColor('#555555')
|
||||
.lineHeight(19)
|
||||
.width('100%')
|
||||
}, (task: string, tIdx: number) => `${wIdx}-${tIdx}`)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(10)
|
||||
.borderRadius(10)
|
||||
.backgroundColor('#F7F8FA')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}, (week: PlanWeekWire, wIdx: number) => `${wIdx}-${week.week ?? 0}`)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
ContentBody() {
|
||||
Column() {
|
||||
@@ -890,9 +1255,15 @@ struct ReportResultPage {
|
||||
}
|
||||
this.CopyCard()
|
||||
this.DiagnosisArea()
|
||||
// 预约留资卡(T-P3-22):内容区底部,导出/重新生成按钮区之前
|
||||
// 深度诊断增量区块(仅 tier=full 报告有 result.full):色板 / 场景 / 30 天计划
|
||||
if (this.hasFull) {
|
||||
this.FullSeasonPaletteCard()
|
||||
this.FullScenesCard()
|
||||
this.FullPlanCard()
|
||||
}
|
||||
// 深度诊断入口卡:内容区底部,导出/重新生成按钮区之前
|
||||
if (this.statusRaw === 'SUCCEEDED') {
|
||||
this.ReservationCard()
|
||||
this.FullReportCard()
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
@@ -915,6 +1286,13 @@ struct ReportResultPage {
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
}
|
||||
if (this.fullError !== '') {
|
||||
Text(this.fullError)
|
||||
.fontSize(12)
|
||||
.fontColor('#E5484D')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
}
|
||||
// 仅成功结案的报告可导出 PDF(服务端同样校验,端侧只是提前挡)
|
||||
if (this.statusRaw === 'SUCCEEDED') {
|
||||
Button(this.exportError !== '' ? '重试导出 PDF' : '导出 PDF 报告')
|
||||
@@ -935,6 +1313,17 @@ struct ReportResultPage {
|
||||
.onClick(() => {
|
||||
this.openConfirm('regen')
|
||||
})
|
||||
// 次级入口:回首页「报告」Tab 查看全部历史报告(initialTab=1)
|
||||
Button('查看历史报告')
|
||||
.width('100%')
|
||||
.height(46)
|
||||
.fontSize(15)
|
||||
.fontColor(COLOR_PRIMARY)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.onClick(() => {
|
||||
const params: Record<string, Object> = { 'initialTab': 1 }
|
||||
router.pushUrl({ url: 'pages/HomePage', params: params })
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 10, bottom: 14 })
|
||||
@@ -951,6 +1340,8 @@ struct ReportResultPage {
|
||||
this.ExportView()
|
||||
} else if (this.regenerating) {
|
||||
this.RegenView()
|
||||
} else if (this.fullGenerating) {
|
||||
this.FullGenView()
|
||||
} else if (this.loading) {
|
||||
// N5':换用 f2 通用加载态(包一层 Column 承接 layoutWeight 占位)
|
||||
Column() {
|
||||
@@ -999,6 +1390,16 @@ struct ReportResultPage {
|
||||
.backgroundColor('#66000000')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
|
||||
if (this.showRegenMissingDialog) {
|
||||
Column() {
|
||||
this.RegenMissingDialog()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#66000000')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
],
|
||||
"installationFree": false,
|
||||
"pages": "$profile:main_pages",
|
||||
"metadata": [
|
||||
{
|
||||
"name": "client_id",
|
||||
"value": "6917613748100335131"
|
||||
}
|
||||
],
|
||||
"abilities": [
|
||||
{
|
||||
"name": "EntryAbility",
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
# 生产部署手册:elane.xiangsilian.com
|
||||
|
||||
> 创建日期:2026-09-14 | 适用:meida_backend 首次公网部署
|
||||
> 目标服务器:elane.xiangsilian.com → 175.178.100.240(腾讯云 Ubuntu,已有 nginx)
|
||||
> 前置:代码 review 已完成(12 项结论全部核实),本文所有命令均按当前代码核实过。
|
||||
|
||||
---
|
||||
|
||||
## 0. 部署前必读(三个硬提醒)
|
||||
|
||||
1. **⚠️ 华为登录还差一步 AGC 配置。** 登录系统已开发完成:邮箱验证码(前后端全
|
||||
链路可用)+ 华为账号登录(端侧已接入,后端验签早就绪)。唯一缺口:AGC 开通
|
||||
Account Kit 后把 **client_id** 填进 App 的 `entry/src/main/module.json5`
|
||||
(替换 `TODO_AGC_CLIENT_ID` 占位值),并确保 AGC 上的包名/证书指纹与本机
|
||||
签名一致。在此之前华为登录按钮会提示「暂未开放」,邮箱登录可正常使用。
|
||||
2. **⚠️ 备案**:`elane.xiangsilian.com` 是现有备案下**新增的子域名**。需在腾讯云
|
||||
控制台「备案 → 变更备案」中提交变更(主体不变、免费、线上办理、约 1~2 周)。
|
||||
部署不依赖备案完成,但华为应用市场审核会核对。
|
||||
3. **⚠️ 服务器安全组**:放行 443;**不要**对公网开放 55432/56379(数据库端口)。
|
||||
|
||||
## 1. 目标架构
|
||||
|
||||
```
|
||||
App ──https──▶ elane.xiangsilian.com:443 (nginx + Let's Encrypt 证书)
|
||||
└─ /api/v1/ ──▶ 127.0.0.1:8666 (gunicorn/uvicorn)
|
||||
└─ /healthz、/readyz ──▶ 127.0.0.1:8666(监控用,可只内网)
|
||||
本机进程:PostgreSQL 16+pgvector(docker, 127.0.0.1:55432)
|
||||
Redis 7(docker, 127.0.0.1:56379)
|
||||
celery worker -Q cpu,io(systemd)
|
||||
数据卷: pgdata / redisdata(docker 卷)+ /home/ubuntu/meida_data/{uploads,exports}
|
||||
```
|
||||
|
||||
说明:本部署为**单进程 api + 单 worker**(当前业务全部走 cpu/io 队列,gpu 队列
|
||||
尚未启用,见 review §10)。不上 K8s,与架构文档 §14.1 的 V1 形态一致。
|
||||
|
||||
## 2. 服务器初始化
|
||||
|
||||
```bash
|
||||
# 系统要求:Ubuntu 22.04+,2C4G 起步(报告生成是 LLM IO 密集型,不吃 CPU)
|
||||
|
||||
# 2.1 Docker(如未装)
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker $USER && newgrp docker
|
||||
|
||||
# 2.2 uv(Python 包管理,项目要求 Python >= 3.12)
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
# 2.3 weasyprint 系统依赖(PDF 导出用,缺了首次导出必炸——review 重点项)
|
||||
sudo apt-get update && sudo apt-get install -y \
|
||||
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0
|
||||
|
||||
# 2.4 nginx + certbot(机器上已有 nginx 则跳过安装,直接进 §5)
|
||||
sudo apt-get install -y nginx certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
## 3. 拉代码 + 数据库
|
||||
|
||||
```bash
|
||||
git clone https://git.xiangsilian.com/xsl/meida_backend.git ~/meida_backend
|
||||
cd ~/meida_backend
|
||||
|
||||
# 3.1 改数据库口令:编辑 deploy/docker-compose.dev.yml
|
||||
# POSTGRES_PASSWORD: meida → 换成强口令(记为 <PG_PASS>)
|
||||
# 并把两个端口映射绑到本机(防公网暴露):
|
||||
# "55432:5432" → "127.0.0.1:55432:5432"
|
||||
# "56379:6379" → "127.0.0.1:56379:6379"
|
||||
|
||||
# 3.2 起 PG + Redis(只起数据层,不起应用)
|
||||
docker compose -f deploy/docker-compose.dev.yml up -d postgres redis
|
||||
|
||||
# 3.3 应用依赖(生产:不装 dev 依赖、锁死版本)
|
||||
uv sync --no-dev --frozen
|
||||
```
|
||||
|
||||
## 4. 生产 .env
|
||||
|
||||
`cp .env.example .env` 后按下表修改。**带 ⚠️ 的行漏配会导致安全事故或拒启动**。
|
||||
|
||||
```bash
|
||||
# ── 环境(最关键:prod 自动关闭 dev-token 登录和 /api/v1/docs)──
|
||||
APP_ENV=prod # ⚠️ 不设这行 = dev-token 对外敞开
|
||||
LOG_LEVEL=INFO
|
||||
LOG_JSON=true
|
||||
|
||||
# ── 数据依赖(默认值指向 localhost:55432/56379,口令与 3.1 一致)──
|
||||
DATABASE_URL=postgresql+asyncpg://meida:<PG_PASS>@127.0.0.1:55432/meida
|
||||
REDIS_URL=redis://127.0.0.1:56379/0
|
||||
CELERY_BROKER_URL=redis://127.0.0.1:56379/1
|
||||
CELERY_RESULT_BACKEND=redis://127.0.0.1:56379/2
|
||||
|
||||
# ── 鉴权 ──
|
||||
JWT_SECRET=<执行 openssl rand -hex 32 生成> # ⚠️ <32 字符或占位符会拒启动(有意保护)
|
||||
DEV_AUTH_ENABLED=false # 双保险(APP_ENV=prod 本身已关)
|
||||
|
||||
# ── LLM(DeepSeek 主 / 智谱备,key 从开发机 .env 抄)──
|
||||
DEEPSEEK_API_KEY=sk-...
|
||||
ZHIPU_API_KEY=...
|
||||
# LLM_DAILY_BUDGET_CENTS=100000 # ⚠️ 当前是占位值(¥1000/天),按实测成本重算后再定
|
||||
|
||||
# ── 存储:无 COS 走本地磁盘降级(当前形态)──
|
||||
# COS_* 全部留空;上传走 POST /api/v1/uploads/direct
|
||||
UPLOAD_DIR=/home/ubuntu/meida_data/uploads # ⚠️ 绝对路径,相对 cwd 在 systemd 下不可靠
|
||||
REPORT_EXPORT_DIR=/home/ubuntu/meida_data/exports
|
||||
|
||||
# ── 登录通道 ──
|
||||
# 华为账号(后端已支持;HUAWEI_AUDIENCE 不配则华为登录 40102,是有意设计)
|
||||
# HUAWEI_AUDIENCE=6917613748100335131
|
||||
# HUAWEI_AUDIENCE 对应 AGC 应用的 App ID(前置开通清单已登记)
|
||||
#
|
||||
# 邮箱验证码(正式兜底通道,前后端已实现,2026-09-14):
|
||||
# ⚠️ EMAIL_PROVIDER=fake 会把验证码明文打进日志,仅限本地开发,生产必须 smtp
|
||||
EMAIL_PROVIDER=smtp
|
||||
SMTP_HOST=smtp.qq.com # 或 smtp.163.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=your_sender@qq.com
|
||||
SMTP_PASS=<邮箱 SMTP 授权码> # QQ/163 邮箱设置里生成,不是登录密码
|
||||
SMTP_USE_SSL=true
|
||||
MAIL_FROM=your_sender@qq.com # 留空则用 SMTP_USER
|
||||
# 频控默认值(对齐短信口径):同邮箱 60s 冷却 / 1h≤5 / 24h≤10,IP 24h≤20,设备 24h≤10
|
||||
#
|
||||
# 短信通道:已废弃(个人主体办不下签名),SMS_* 保持默认不启用即可
|
||||
```
|
||||
|
||||
> 注:短信通道已废弃不做。`SMS_PROVIDER` 保持默认 fake 即可——短信端点未接路由、
|
||||
> App 端无入口,永远不会被触发;但生产日志审计时留意别让任何人把它接到路由上。
|
||||
|
||||
## 5. 迁移 + 内测配额口径
|
||||
|
||||
```bash
|
||||
cd ~/meida_backend
|
||||
|
||||
# 5.1 迁移(alembic 走 DATABASE_URL;0003 会自动灌 bl_quota_rule 种子,
|
||||
# 含 full_report 行——全新库不需要手动补)
|
||||
uv run alembic upgrade head
|
||||
|
||||
# 5.2 (可选,内测期放宽;上架前必须改回)开发库当前口径:
|
||||
docker exec meida-postgres psql -U meida -d meida -c \
|
||||
"UPDATE bl_quota_rule SET base_limit=base_limit*10, task_bonus_max=task_bonus_max*10, hard_cap=hard_cap*10;"
|
||||
```
|
||||
|
||||
## 6. 进程常驻(systemd)
|
||||
|
||||
```bash
|
||||
sudo tee /etc/systemd/system/meida-api.service <<'EOF'
|
||||
[Unit]
|
||||
Description=meida api
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/meida_backend
|
||||
Environment=PATH=/home/ubuntu/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/home/ubuntu/.local/bin/uv run --no-sync gunicorn apps.api.main:app \
|
||||
--worker-class uvicorn.workers.UvicornWorker --workers 2 \
|
||||
--bind 127.0.0.1:8666 --timeout 120 --graceful-timeout 30 --access-logfile -
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo tee /etc/systemd/system/meida-worker.service <<'EOF'
|
||||
[Unit]
|
||||
Description=meida celery worker (cpu,io)
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/meida_backend
|
||||
Environment=PATH=/home/ubuntu/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/home/ubuntu/.local/bin/uv run --no-sync celery -A apps.worker.celery_app worker \
|
||||
-Q cpu,io --pool=solo --loglevel=INFO
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now meida-api meida-worker
|
||||
|
||||
# 验证(本机)
|
||||
curl -s http://127.0.0.1:8666/healthz # {"code":0,...,"status":"ok"}
|
||||
curl -s http://127.0.0.1:8666/readyz # 依赖就绪(查 PG+Redis)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8666/api/v1/auth/dev-token \
|
||||
-H 'Content-Type: application/json' -d '{"device_key":"x"}' # 必须是 404(dev-token 已关)
|
||||
```
|
||||
|
||||
要点:
|
||||
- `--no-sync` 防止 systemd 环境下 uv 试图联网解析依赖;依赖已在 §3.3 装好。
|
||||
- 不需要 celery beat(全仓库无定时任务;temp 目录清理等上线后再说,见 §8)。
|
||||
- worker 与 api 的 UPLOAD_DIR/REPORT_EXPORT_DIR 是同一绝对路径,天然共享,无需挂卷。
|
||||
|
||||
## 7. nginx + HTTPS
|
||||
|
||||
```bash
|
||||
# 7.1 签证书(自动改 nginx 配置 + 配置续期)
|
||||
sudo certbot --nginx -d elane.xiangsilian.com
|
||||
|
||||
# 7.2 编辑 /etc/nginx/sites-enabled/ 下 elane 的 server 块,追加反代:
|
||||
```
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name elane.xiangsilian.com;
|
||||
# certbot 自动填 ssl_certificate / ssl_certificate_key
|
||||
|
||||
location /api/v1/ {
|
||||
proxy_pass http://127.0.0.1:8666;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
client_max_body_size 20m; # 上传上限与 UPLOAD_MAX_BYTES 对齐
|
||||
proxy_read_timeout 120s; # 报告生成等长请求
|
||||
}
|
||||
|
||||
# 监控探活(可选;不配则只在服务器本机 curl)
|
||||
location = /healthz { proxy_pass http://127.0.0.1:8666/healthz; }
|
||||
location = /readyz { proxy_pass http://127.0.0.1:8666/readyz; }
|
||||
|
||||
# /metrics 不要暴露公网(代码里它不受 PROMETHEUS_ENABLED 控制,见 §8)
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
# 7.3 安全组:腾讯云控制台放行 443/tcp;确认 55432/56379/8666 未对公网开放
|
||||
|
||||
# 7.4 公网验证
|
||||
curl -s https://elane.xiangsilian.com/healthz
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://elane.xiangsilian.com/api/v1/quota # 401 = 服务活了
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://elane.xiangsilian.com/api/v1/docs # 404 = swagger 已关
|
||||
```
|
||||
|
||||
## 8. 已知限制与待办(代码 review 结论,部署后按此跟进)
|
||||
|
||||
| # | 事项 | 处置 |
|
||||
|---|---|---|
|
||||
| 1 | **华为登录差 client_id**:端侧已接入(占位拦截不 crash),需 AGC 开通 Account Kit 后填 `module.json5` | 用户在 AGC 操作(前置开通清单 §1),拿到 client_id 替换 `TODO_AGC_CLIENT_ID` |
|
||||
| 2 | ~~SMS_PROVIDER 默认 fake~~ 短信已废弃;邮箱通道已上线 | 生产 `.env` 用 `EMAIL_PROVIDER=smtp` + 真实邮箱授权码 |
|
||||
| 3 | CORS 中间件不存在 | 原生 App 不受影响;admin 后台浏览器访问前必须加白名单 |
|
||||
| 4 | COS temp 桶 2 小时生命周期 | 未接 COS 前, uploads 目录只增不减,需 cron 清理:如 `find /home/ubuntu/meida_data/uploads -type f -mtime +2 -delete`(temp 场景文件 2 小时后可删) |
|
||||
| 5 | 数据备份脚本缺失(FR-SYS-09) | 最低限度:`pg_dump` 每日 cron + `meida_data/` 快照 |
|
||||
| 6 | `LLM_DAILY_BUDGET_CENTS` 是占位值 | 用真实 token 成本重算后改 .env |
|
||||
| 7 | `/metrics` 无条件暴露 | 已按 §7 不反代公网;要监控可后期加 IP 白名单 |
|
||||
| 8 | DEBUG 配置项是死代码 | 无行为影响,忽略即可 |
|
||||
| 9 | 本地上传的照片无对外 URL(已知缺口) | 报告场景服务端自用不受影响;需回显时接 COS |
|
||||
| 10 | 运营后台(行为分析)需设 `ADMIN_TOKEN` 才装配 | 端点 `/admin/api/v1/users`、`/users/{id}`、`/events`、`/stats/overview`(X-Admin-Token 鉴权,PII 掩码后输出);admin 是独立应用需单独进程:`uv run uvicorn "apps.admin.app:create_admin_app" --host 127.0.0.1 --port 8001`,nginx 按需反代(注意先解决 #3 CORS) |
|
||||
|
||||
## 9. 故障速查
|
||||
|
||||
| 症状 | 排查 |
|
||||
|---|---|
|
||||
| `systemctl status meida-api` 启动即失败 | 多半是 JWT_SECRET 太短/占位符(config.py 启动校验)——`journalctl -u meida-api -n 50` 看原因 |
|
||||
| readyz 非 2xx | PG/Redis 没起:`docker compose -f deploy/docker-compose.dev.yml ps` |
|
||||
| 报告卡在生成中 | 看 worker:`journalctl -u meida-worker -f`;LLM key 失效是最常见原因 |
|
||||
| PDF 导出失败报 weasyprint 相关错 | §2.3 的系统库没装 |
|
||||
| nginx 502 | api 没起 / 8666 端口不对;`ss -ltnp \| grep 8666` |
|
||||
| 华为登录 40102 | HUAWEI_AUDIENCE 没配(预期行为,配 AGC App ID) |
|
||||
|
||||
## 10. 上线前最终检查单
|
||||
|
||||
- [ ] 变更备案通过(elane.xiangsilian.com 在备案系统可见)
|
||||
- [ ] dev-token 返回 404、docs 返回 404、metrics 不公网可达
|
||||
- [ ] JWT_SECRET / PG 口令均为强随机值,`.env` 权限 `chmod 600`
|
||||
- [ ] 华为登录真机回归通过(client_id 已填入 module.json5、AGC 指纹与签名一致)
|
||||
- [ ] 邮箱验证码登录真机回归通过(生产 EMAIL_PROVIDER=smtp 实测收到邮件)
|
||||
- [ ] 账号注销功能可用(华为审核硬性要求)
|
||||
- [ ] 隐私政策/用户协议可公开访问,App 内有入口
|
||||
- [ ] 配额规则从内测口径改回产品口径(若执行过 §5.2)
|
||||
- [ ] pg_dump 定时备份已配并演练过一次恢复
|
||||
- [ ] App `DEFAULT_BASE_URL` 切换为 `https://elane.xiangsilian.com`,Release 签名出包
|
||||
+1
-1
Submodule meida_backend updated: a1d0171fc5...90349f735a
Reference in New Issue
Block a user