Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0075c130bd | ||
|
|
2b0df49aa7 | ||
|
|
eb86810a77 | ||
|
|
247da63509 | ||
|
|
1a6fde919b | ||
|
|
f244f14ab8 | ||
|
|
fcc506920f | ||
|
|
9161a2013c | ||
|
|
aafacbf020 | ||
|
|
a850735d60 | ||
|
|
9228f67820 | ||
|
|
8b3a66a9fc | ||
|
|
1b534f5133 | ||
|
|
d2c603566b | ||
|
|
20fcef71f6 | ||
|
|
cc9db866dc | ||
|
|
3a7bbafc46 | ||
|
|
8a901176c4 | ||
|
|
f17868233a |
@@ -18,14 +18,17 @@ import router from '@ohos.router'
|
||||
// 即使 clean 构建该文件也先落盘(#19:release 构建隐藏调试入口的判定来源)
|
||||
import BuildProfile from '../../../../../build/default/generated/profile/default/BuildProfile'
|
||||
import { ApiClient } from '../../core/ApiClient'
|
||||
import { ApiError, isAuthError } from '../../core/ApiTypes'
|
||||
import { ApiError, isAuthError, isNetworkError } from '../../core/ApiTypes'
|
||||
import { AuthStore } from '../../core/AuthStore'
|
||||
import { COLOR_PRIMARY } from '../../core/Theme'
|
||||
import { FaceConsentStore } from '../f2/FacePhotoConsent'
|
||||
import { NetworkBanner } from '../f2/NetworkBanner'
|
||||
import { Tracker } from '../../core/Tracker'
|
||||
import { formatIsoTime } from '../f3/ReportTypes'
|
||||
import { ErrorBanner } from './ErrorBanner'
|
||||
import { QuotaCardView } from './QuotaCard'
|
||||
import { LedgerItemView } from './LedgerItem'
|
||||
import { TaskBonusPanel } from './TaskBonusPanel'
|
||||
|
||||
/** GET /api/v1/quota 响应(字段名对齐后端 JSON)。 */
|
||||
interface QuotaWire {
|
||||
@@ -240,8 +243,10 @@ export struct ProfileContent {
|
||||
@State bannerMessage: string = ''
|
||||
@State bannerHint: string = ''
|
||||
@State authExpired: boolean = false // 会话/令牌类失败:给「重新登录」出路(#7)
|
||||
@State networkFailed: boolean = false // 网络/服务类失败(P0-6):顶部 NetworkBanner;与 authExpired 互斥
|
||||
@State refreshing: boolean = false
|
||||
@State loggingOut: boolean = false
|
||||
@State showTaskPanel: boolean = false // 「做任务换额度」半屏面板(P0-4)
|
||||
@State faceConsentGranted: boolean = false // 人脸照片授权状态(N4:查看/撤回入口数据源)
|
||||
private pendingLoads: number = 0
|
||||
|
||||
@@ -269,6 +274,7 @@ export struct ProfileContent {
|
||||
this.bannerMessage = ''
|
||||
this.bannerHint = ''
|
||||
this.authExpired = false
|
||||
this.networkFailed = false
|
||||
this.ledgerError = ''
|
||||
this.cardChat = new QuotaCardData('AI 对话')
|
||||
this.cardReport = new QuotaCardData('形象报告')
|
||||
@@ -305,6 +311,9 @@ export struct ProfileContent {
|
||||
// 单卡失败不拖垮整页:错误落在卡片内;会话过期类单独记,供横幅给重新登录出路
|
||||
if (isAuthError(e)) {
|
||||
this.authExpired = true
|
||||
} else if (isNetworkError(e)) {
|
||||
// P0-6:网络/服务类失败上顶部 NetworkBanner(重试有意义)
|
||||
this.networkFailed = true
|
||||
}
|
||||
const err = toApiError(e)
|
||||
const data = new QuotaCardData(quotaName(key))
|
||||
@@ -341,6 +350,8 @@ export struct ProfileContent {
|
||||
} catch (e) {
|
||||
if (isAuthError(e)) {
|
||||
this.authExpired = true
|
||||
} else if (isNetworkError(e)) {
|
||||
this.networkFailed = true
|
||||
}
|
||||
const err = toApiError(e)
|
||||
this.ledgerLoaded = true
|
||||
@@ -404,6 +415,16 @@ export struct ProfileContent {
|
||||
}
|
||||
}
|
||||
|
||||
/** 「做任务换额度」半屏面板(P0-4):claim 到账后全量刷新四张配额卡。 */
|
||||
@Builder
|
||||
TaskBonusSheet() {
|
||||
TaskBonusPanel({
|
||||
onClaimed: (_quotaKey: string): void => {
|
||||
this.startLoadAll()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 未登录占位:页内提示 + 去登录(不强制跳转)。 */
|
||||
@Builder
|
||||
LoginPrompt() {
|
||||
@@ -442,6 +463,16 @@ export struct ProfileContent {
|
||||
Refresh({ refreshing: $$this.refreshing }) {
|
||||
Scroll() {
|
||||
Column({ space: 14 }) {
|
||||
// P0-6:网络/服务类失败横幅(与 authExpired 的「重新登录」出路互斥展示)
|
||||
if (this.networkFailed && !this.authExpired) {
|
||||
NetworkBanner({
|
||||
onRetry: (): void => {
|
||||
Tracker.track('network.banner_retry', { 'page': 'profile' })
|
||||
this.startLoadAll()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 顶部:用户信息 + 登出
|
||||
Row({ space: 12 }) {
|
||||
Column() {
|
||||
@@ -619,6 +650,25 @@ export struct ProfileContent {
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
|
||||
// 「做任务换额度」入口(P0-4 / FR-BILL-03):配额四卡下方,点开半屏任务面板
|
||||
Row({ space: 8 }) {
|
||||
Text('做任务换额度')
|
||||
.fontSize(14)
|
||||
.fontColor('#2B2B2B')
|
||||
.layoutWeight(1)
|
||||
Text('每日签到领 AI 对话次数 ›')
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_PRIMARY)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
|
||||
.borderRadius(16)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.showTaskPanel = true
|
||||
})
|
||||
|
||||
// 最近流水
|
||||
Column({ space: 4 }) {
|
||||
Text('最近流水')
|
||||
@@ -690,6 +740,10 @@ export struct ProfileContent {
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
.bindSheet($$this.showTaskPanel, this.TaskBonusSheet(), {
|
||||
height: '60%',
|
||||
showClose: true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* TaskBonusPanel —— 「做任务换额度」任务列表面板(P0-4 / FR-BILL-03/04,T2b 闭环)。
|
||||
*
|
||||
* 供弹层/区块复用:ProfileContent 入口卡、ChatPage 耗尽面板、试穿/发型软限制
|
||||
* 耗尽面板都以 bindSheet 承载本组件。
|
||||
*
|
||||
* 纪律:
|
||||
* - 任务表本地静态(产品方案 §3.2 定稿 4 项,invite_friend/profile_complete 已
|
||||
* 下架);desc 里的奖励数值是定稿展示文案,实际到账仍以 claim 响应 amount 为准;
|
||||
* - 文案纪律(FR-BILL-07):不出现价格/购买/会员/升级字样;
|
||||
* - 已领判定走 core/TaskBonusState(preferences meida_task_bonus 的共享读写,
|
||||
* 与报告页反馈/分享/首测的自动 claim 互通),跨重启保留;claim 返回 40910
|
||||
* (本周期已领)时同步标记——本地状态永远以服务端口径为准;
|
||||
* - 「去完成」真实跳转:first_report → 速测落地页;report_feedback / share_report
|
||||
* → 报告链路页(无报告时给引导 toast,不报错);sign_in 点击即领。
|
||||
* first_report 在服务端已有完成报告且未领过时直接变为「领额度」走 claim。
|
||||
*/
|
||||
|
||||
import { promptAction, router } from '@kit.ArkUI'
|
||||
import { ApiClient } from '../../core/ApiClient'
|
||||
import { ApiError } from '../../core/ApiTypes'
|
||||
import { TaskBonusState, PERIOD_DAY, PERIOD_LIFETIME } from '../../core/TaskBonusState'
|
||||
import { Tracker } from '../../core/Tracker'
|
||||
import {
|
||||
COLOR_PRIMARY,
|
||||
COLOR_TEXT_PRIMARY,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
COLOR_TEXT_DISABLED,
|
||||
COLOR_IMAGE_BG
|
||||
} from '../../core/Theme'
|
||||
|
||||
/** POST /api/v1/quota/tasks/{task_key}/claim 响应(字段名对齐后端 JSON)。 */
|
||||
interface ClaimWire {
|
||||
task_key: string
|
||||
quota_key: string
|
||||
amount: number
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
}
|
||||
|
||||
/** GET /api/v1/reports 列表元素(本面板只消费这两个字段)。 */
|
||||
interface ReportListItemWire {
|
||||
report_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务定义。direct=true 表示点击即调 claim(签到);
|
||||
* false 为「去完成」跳转(first_report 在服务端已有完成报告时升格为可领)。
|
||||
* successToast 是领取成功的 toast 前缀,amount 取响应值拼接。
|
||||
*/
|
||||
interface TaskDef {
|
||||
key: string
|
||||
icon: string
|
||||
name: string
|
||||
desc: string
|
||||
period: string // day / lifetime
|
||||
direct: boolean
|
||||
successToast: string
|
||||
}
|
||||
|
||||
/** 每日任务(task_key 与后端 TASK_BONUS_RULES_JSON 定稿目录对齐)。 */
|
||||
const DAILY_TASKS: TaskDef[] = [
|
||||
{
|
||||
key: 'sign_in', icon: '✓', name: '每日签到', desc: '每日 · 领 10 轮 AI 对话额度',
|
||||
period: PERIOD_DAY, direct: true, successToast: '签到成功'
|
||||
},
|
||||
{
|
||||
key: 'report_feedback', icon: '✎', name: '给报告结论打反馈', desc: '每日 · 领 2 次发型试戴额度',
|
||||
period: PERIOD_DAY, direct: false, successToast: '感谢反馈'
|
||||
},
|
||||
{
|
||||
key: 'share_report', icon: '↗', name: '分享我的报告', desc: '每日 · 领 3 次换装额度',
|
||||
period: PERIOD_DAY, direct: false, successToast: '感谢分享'
|
||||
}
|
||||
]
|
||||
|
||||
/** 新手任务(一次性)。 */
|
||||
const NEWBIE_TASKS: TaskDef[] = [
|
||||
{
|
||||
key: 'first_report', icon: '★', name: '完成首次形象速测', desc: '一次性 · 领 1 次报告重生成额度',
|
||||
period: PERIOD_LIFETIME, direct: false, successToast: '形象速测完成'
|
||||
}
|
||||
]
|
||||
|
||||
/** 本周期已领 / 奖励封顶 / 任务未完成 / 未知任务(与后端 errors.py 对齐)。 */
|
||||
const CODE_ALREADY_CLAIMED: number = 40910
|
||||
const CODE_BONUS_CAPPED: number = 40310
|
||||
const CODE_NOT_ELIGIBLE: number = 40311
|
||||
|
||||
@Component
|
||||
export struct TaskBonusPanel {
|
||||
/** claim 成功回调:quotaKey 为到账的配额 key,宿主据此刷新对应额度卡。 */
|
||||
onClaimed: (quotaKey: string) => void = () => {}
|
||||
|
||||
@State claimingKey: string = '' // 领取中的 task_key(防空点)
|
||||
@State claimsTick: number = 0 // claims 变更计数:驱动已领态重渲染
|
||||
@State firstReportReady: boolean = false // 服务端已有完成报告:first_report 可直接领
|
||||
@State latestReportId: string = '' // 报告链路跳转用(优先最新一份已完成报告)
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.hydrate()
|
||||
}
|
||||
|
||||
/** 已领态由 TaskBonusState 共享(报告页自动 claim 也要互通):hydrate + 曝光埋点 + 报告态探测。 */
|
||||
private async hydrate(): Promise<void> {
|
||||
await TaskBonusState.init()
|
||||
this.claimsTick += 1
|
||||
Tracker.track('task_bonus.panel_view')
|
||||
this.refreshReportState()
|
||||
}
|
||||
|
||||
/** 拉我的报告列表,定 first_report 可领态与报告链路跳转目标;失败静默(点击时再实时拉)。 */
|
||||
private async refreshReportState(): Promise<void> {
|
||||
try {
|
||||
const list = await ApiClient.get<ReportListItemWire[]>('/api/v1/reports')
|
||||
const succeeded: ReportListItemWire | undefined =
|
||||
list.find((r: ReportListItemWire) => r.status === 'succeeded')
|
||||
this.firstReportReady = succeeded !== undefined
|
||||
this.latestReportId = succeeded !== undefined
|
||||
? succeeded.report_id
|
||||
: (list.length > 0 ? list[0].report_id : '')
|
||||
} catch (_e) {
|
||||
// 网络失败不打扰:面板其余功能照常,「去完成」点击时再拉一次
|
||||
}
|
||||
}
|
||||
|
||||
/** 实时取最新报告 id(跳转前兜底再拉一次,hydrate 失败也能补救);无报告返回 ''。 */
|
||||
private async fetchLatestReportId(): Promise<string> {
|
||||
try {
|
||||
const list = await ApiClient.get<ReportListItemWire[]>('/api/v1/reports')
|
||||
const succeeded: ReportListItemWire | undefined =
|
||||
list.find((r: ReportListItemWire) => r.status === 'succeeded')
|
||||
const rid: string = succeeded !== undefined
|
||||
? succeeded.report_id
|
||||
: (list.length > 0 ? list[0].report_id : '')
|
||||
this.latestReportId = rid
|
||||
this.firstReportReady = succeeded !== undefined
|
||||
return rid
|
||||
} catch (_e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
private isClaimed(task: TaskDef): boolean {
|
||||
return TaskBonusState.isClaimed(task.key, task.period)
|
||||
}
|
||||
|
||||
private async markClaimed(task: TaskDef): Promise<void> {
|
||||
await TaskBonusState.markClaimed(task.key, task.period)
|
||||
this.claimsTick += 1
|
||||
}
|
||||
|
||||
private toast(message: string): void {
|
||||
try {
|
||||
promptAction.showToast({ message: message, duration: 2000 })
|
||||
} catch (_e) {
|
||||
// 无 UIContext 的极端场景:toast 失败不阻断主流程
|
||||
}
|
||||
}
|
||||
|
||||
private onTapTask(task: TaskDef): void {
|
||||
if (this.isClaimed(task) || this.claimingKey !== '') {
|
||||
return
|
||||
}
|
||||
Tracker.track('task_bonus.task_tap', { 'task_key': task.key })
|
||||
if (task.direct) {
|
||||
this.claim(task)
|
||||
return
|
||||
}
|
||||
if (task.key === 'first_report') {
|
||||
if (this.firstReportReady) {
|
||||
// 服务端已有完成报告且未领过:直接领(服务端还会再校验一次 rp_report)
|
||||
this.claim(task)
|
||||
} else {
|
||||
router.pushUrl({ url: 'pages/DiagnosisLandingPage' })
|
||||
}
|
||||
return
|
||||
}
|
||||
// report_feedback / share_report:跳报告链路页(反馈在报告结果页,分享经
|
||||
// 报告结果页导出 PDF 后到预览页;ReportPdfPage 需要本地 pdfPath 无法直达)
|
||||
this.goReportChain()
|
||||
}
|
||||
|
||||
private async goReportChain(): Promise<void> {
|
||||
let rid: string = this.latestReportId
|
||||
if (rid === '') {
|
||||
rid = await this.fetchLatestReportId()
|
||||
}
|
||||
if (rid === '') {
|
||||
this.toast('先生成你的形象速测,再来反馈')
|
||||
return
|
||||
}
|
||||
const params: Record<string, Object> = { 'reportId': rid }
|
||||
router.pushUrl({ url: 'pages/ReportResultPage', params: params })
|
||||
}
|
||||
|
||||
private async claim(task: TaskDef): Promise<void> {
|
||||
this.claimingKey = task.key
|
||||
try {
|
||||
const req: Record<string, Object> = {}
|
||||
const d = await ApiClient.post<ClaimWire>(`/api/v1/quota/tasks/${task.key}/claim`, req)
|
||||
await this.markClaimed(task)
|
||||
this.toast(`${task.successToast},额度 +${d.amount}`)
|
||||
Tracker.track('task_bonus.claim_result', { 'task_key': task.key, 'ok': true })
|
||||
this.onClaimed(d.quota_key)
|
||||
} catch (e) {
|
||||
const err: ApiError = e instanceof ApiError ? e : new ApiError(50000, '网络异常,请稍后重试')
|
||||
if (err.code === CODE_ALREADY_CLAIMED) {
|
||||
// 服务端说本周期已领:同步本地已领态(换机/重装后的对齐通道)
|
||||
await this.markClaimed(task)
|
||||
this.toast('今天已经领过啦')
|
||||
} else if (err.code === CODE_BONUS_CAPPED) {
|
||||
this.toast('今日任务奖励已达上限,明天再来')
|
||||
} else if (err.code === CODE_NOT_ELIGIBLE) {
|
||||
this.toast('先完成任务,再来领奖励')
|
||||
} else {
|
||||
this.toast(err.message)
|
||||
}
|
||||
Tracker.track('task_bonus.claim_result', { 'task_key': task.key, 'ok': false, 'code': err.code })
|
||||
} finally {
|
||||
this.claimingKey = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 按钮文案/置灰:已领 > 领取中 > 可领取/去完成。 */
|
||||
private actionText(task: TaskDef): string {
|
||||
if (this.isClaimed(task)) {
|
||||
return task.period === PERIOD_LIFETIME ? '已领取' : '今日已领'
|
||||
}
|
||||
if (this.claimingKey === task.key) {
|
||||
return '领取中…'
|
||||
}
|
||||
if (task.direct || (task.key === 'first_report' && this.firstReportReady)) {
|
||||
return '领额度'
|
||||
}
|
||||
return '去完成'
|
||||
}
|
||||
|
||||
@Builder
|
||||
TaskRow(task: TaskDef) {
|
||||
Row({ space: 12 }) {
|
||||
Text(task.icon)
|
||||
.fontSize(18)
|
||||
.fontColor(COLOR_PRIMARY)
|
||||
.width(40)
|
||||
.height(40)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(12)
|
||||
.backgroundColor(COLOR_IMAGE_BG)
|
||||
Column({ space: 3 }) {
|
||||
Text(task.name)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
Text(task.desc)
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_TEXT_SECONDARY)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
Text(this.claimsTick >= 0 ? this.actionText(task) : '')
|
||||
.fontSize(13)
|
||||
.fontColor(this.isClaimed(task) ? COLOR_TEXT_DISABLED : COLOR_PRIMARY)
|
||||
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
|
||||
.borderRadius(16)
|
||||
.backgroundColor(this.isClaimed(task) ? COLOR_IMAGE_BG : '#F7E3EA')
|
||||
.onClick(() => {
|
||||
this.onTapTask(task)
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 10, bottom: 10 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
@Builder
|
||||
GroupHeader(title: string) {
|
||||
Text(title)
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_TEXT_SECONDARY)
|
||||
.width('100%')
|
||||
.margin({ top: 8, bottom: 2 })
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 4 }) {
|
||||
Text('做任务换额度')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
.width('100%')
|
||||
.margin({ bottom: 4 })
|
||||
this.GroupHeader('每日任务')
|
||||
ForEach(DAILY_TASKS, (task: TaskDef) => {
|
||||
this.TaskRow(task)
|
||||
}, (task: TaskDef) => `${task.key}_${this.isClaimed(task)}`)
|
||||
this.GroupHeader('新手任务')
|
||||
ForEach(NEWBIE_TASKS, (task: TaskDef) => {
|
||||
this.TaskRow(task)
|
||||
}, (task: TaskDef) => `${task.key}_${this.isClaimed(task)}`)
|
||||
Text('奖励以实际到账为准,额度每日重置')
|
||||
.fontSize(11)
|
||||
.fontColor(COLOR_TEXT_DISABLED)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.margin({ top: 8 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 16, bottom: 24 })
|
||||
.backgroundColor(Color.White)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* CapsuleButton —— 「试穿」黑色竖向胶囊(《美搭配与美学诊断-UI规范》§3/§7,需求 v2 §3.2)。
|
||||
*
|
||||
* 形态:44×96 竖条(v2.1 §10.4 紧凑化),黑底白字 14(COLOR_BLACK_CAPSULE),
|
||||
* 右缘悬浮垂直居中;未选上衣/未传本人照/生成中置灰(§2 主按钮禁用态:
|
||||
* 底 COLOR_DIVIDER、字 COLOR_TEXT_DISABLED)。label 支持换行竖排(如 '试\n穿')。
|
||||
*/
|
||||
|
||||
import { COLOR_BLACK_CAPSULE, COLOR_DIVIDER, COLOR_TEXT_DISABLED } from '../../core/Theme'
|
||||
|
||||
@Component
|
||||
export struct CapsuleButton {
|
||||
label: string = ''
|
||||
@Prop isActive: boolean = true
|
||||
onAction: () => void = () => {}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Text(this.label)
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.isActive ? Color.White : COLOR_TEXT_DISABLED)
|
||||
.textAlign(TextAlign.Center)
|
||||
}
|
||||
.width(44)
|
||||
.height(96)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.borderRadius(22)
|
||||
.backgroundColor(this.isActive ? COLOR_BLACK_CAPSULE : COLOR_DIVIDER)
|
||||
.onClick(() => {
|
||||
if (this.isActive) {
|
||||
this.onAction()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* CategoryChip —— 品类 chip(《美搭配与美学诊断-UI规范》§3/§7)。
|
||||
*
|
||||
* 形态:高约 28 胶囊(§10.4 紧凑化);选中黑底白字(COLOR_BLACK_CAPSULE,试衣间专属黑),
|
||||
* 未选白底 + COLOR_DIVIDER 描边。用于品类过滤条与「添加衣服」品类弹层。
|
||||
* selected 随父层状态变化,必须 @Prop(普通成员只在创建时赋值一次,不随父刷新)。
|
||||
*/
|
||||
|
||||
import { COLOR_BLACK_CAPSULE, COLOR_DIVIDER, COLOR_TEXT_PRIMARY } from '../../core/Theme'
|
||||
|
||||
@Component
|
||||
export struct CategoryChip {
|
||||
label: string = ''
|
||||
@Prop selected: boolean = false
|
||||
onPick: () => void = () => {}
|
||||
|
||||
build() {
|
||||
Text(this.label)
|
||||
.fontSize(12)
|
||||
.fontColor(this.selected ? Color.White : COLOR_TEXT_PRIMARY)
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.selected ? COLOR_BLACK_CAPSULE : Color.White)
|
||||
.borderRadius(14)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.selected ? Color.Transparent : COLOR_DIVIDER
|
||||
})
|
||||
.onClick(() => {
|
||||
this.onPick()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* ClothCard —— 衣橱服装卡(《美搭配与美学诊断-UI规范》§10.2 v2.1 三列纯图卡)。
|
||||
*
|
||||
* 形态:图 3:4 Cover(约 104×139),无名称/品类文字行(三列窄卡放不下,
|
||||
* 品类改图内左下小角标:黑 60% 底白字 10);选中 2vp 主色描边 + 右上槽位角标;
|
||||
* 我的衣橱卡保留 20×20 删除角标,内置精选(rawfilePath 非空)无删除、无失效态。
|
||||
* 结构说明:角标行与卡片主体是 Stack 兄弟节点(非父子),点击角标不会误触选择;
|
||||
* selected/slotLabel 随父层选择状态变化,必须 @Prop。
|
||||
*/
|
||||
|
||||
import { fileUri } from '@kit.CoreFileKit'
|
||||
import { WardrobeItem } from '../../core/WardrobeStore'
|
||||
import {
|
||||
COLOR_BADGE_BG,
|
||||
COLOR_IMAGE_BG,
|
||||
COLOR_PRIMARY,
|
||||
COLOR_TEXT_SECONDARY
|
||||
} from '../../core/Theme'
|
||||
|
||||
@Component
|
||||
export struct ClothCard {
|
||||
item: WardrobeItem = { id: '', uri: '', category: '', name: '', createdAt: 0 }
|
||||
@Prop selected: boolean = false
|
||||
@Prop slotLabel: string = '' // 选中时的槽位角标(上衣/下装);空=未选中
|
||||
rawfilePath: string = '' // 非空=内置精选($rawfile 渲染,无删除角标、无失效态)
|
||||
onSelect: () => void = () => {}
|
||||
onDelete: () => void = () => {}
|
||||
@State loadFailed: boolean = false
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.TopEnd }) {
|
||||
// 卡片主体(点击=选择/取消;失效时点击=删除)
|
||||
Column() {
|
||||
if (this.loadFailed) {
|
||||
Column({ space: 4 }) {
|
||||
Text('已失效,点按删除')
|
||||
.fontSize(11)
|
||||
.fontColor(COLOR_TEXT_SECONDARY)
|
||||
.textAlign(TextAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.aspectRatio(3 / 4)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding(8)
|
||||
.backgroundColor(COLOR_IMAGE_BG)
|
||||
} else {
|
||||
Stack({ alignContent: Alignment.BottomStart }) {
|
||||
if (this.rawfilePath !== '') {
|
||||
Image($rawfile(this.rawfilePath))
|
||||
.width('100%')
|
||||
.aspectRatio(3 / 4)
|
||||
.objectFit(ImageFit.Cover)
|
||||
} else {
|
||||
Image(fileUri.getUriFromPath(this.item.uri))
|
||||
.width('100%')
|
||||
.aspectRatio(3 / 4)
|
||||
.objectFit(ImageFit.Cover)
|
||||
.onError(() => {
|
||||
this.loadFailed = true
|
||||
})
|
||||
}
|
||||
// 左下品类角标(黑 60% 底白字 10)
|
||||
Text(this.item.category)
|
||||
.fontSize(10)
|
||||
.fontColor(Color.White)
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.borderRadius(8)
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
.margin(6)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.backgroundColor(COLOR_IMAGE_BG)
|
||||
.borderRadius(12)
|
||||
.clip(true)
|
||||
.border({
|
||||
width: this.selected ? 2 : 0,
|
||||
color: COLOR_PRIMARY
|
||||
})
|
||||
.onClick(() => {
|
||||
if (this.loadFailed) {
|
||||
this.onDelete()
|
||||
} else {
|
||||
this.onSelect()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 右上角标行(槽位角标 + 删除角标;内置精选无删除)
|
||||
Row({ space: 4 }) {
|
||||
if (this.slotLabel !== '') {
|
||||
Text(this.slotLabel)
|
||||
.fontSize(10)
|
||||
.fontColor(Color.White)
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.borderRadius(8)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
}
|
||||
if (this.rawfilePath === '') {
|
||||
Text('×')
|
||||
.fontSize(13)
|
||||
.fontColor(Color.White)
|
||||
.width(20)
|
||||
.height(20)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(10)
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
.onClick(() => {
|
||||
this.onDelete()
|
||||
})
|
||||
}
|
||||
}
|
||||
.margin(4)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* ColorDot —— 发色圆点+名称(《美搭配与美学诊断-UI规范》§4/§7)。
|
||||
*
|
||||
* 形态:32vp 圆点,名称 11 在点下;选中态外环 2vp COLOR_PRIMARY。
|
||||
* 圆点颜色由使用方传入(发型页 PRESET_COLORS 的 RGB 转 #RRGGBB),
|
||||
* 组件不持有色板数据,发色接口的 rgb 传参格式不受影响。
|
||||
*/
|
||||
|
||||
import { COLOR_PRIMARY, COLOR_TEXT_PRIMARY, COLOR_TEXT_SECONDARY } from '../../core/Theme'
|
||||
|
||||
@Component
|
||||
export struct ColorDot {
|
||||
colorHex: string = '#000000'
|
||||
label: string = ''
|
||||
selected: boolean = false
|
||||
onSelect: () => void = () => {}
|
||||
|
||||
build() {
|
||||
Column({ space: 4 }) {
|
||||
Row()
|
||||
.width(32)
|
||||
.height(32)
|
||||
.borderRadius(16)
|
||||
.backgroundColor(this.colorHex)
|
||||
.border({
|
||||
width: 2,
|
||||
color: this.selected ? COLOR_PRIMARY : Color.Transparent
|
||||
})
|
||||
Text(this.label)
|
||||
.fontSize(11)
|
||||
.fontColor(this.selected ? COLOR_TEXT_PRIMARY : COLOR_TEXT_SECONDARY)
|
||||
.maxLines(1)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.onSelect()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { COLOR_PRIMARY } from '../../core/Theme'
|
||||
const CONSENT_STORAGE_KEY: string = 'face_photo_consent_granted'
|
||||
const CONSENT_PREF_NAME: string = 'meida_face_consent'
|
||||
const CONSENT_RECORD_KEY: string = 'record'
|
||||
const CONSENT_VERSION: string = 'v1'
|
||||
const CONSENT_VERSION: string = 'v2' // v2:处理方/保存文案按本地磁盘降级路线改为属实表述(2026-09-12)
|
||||
const TEXT_PRIMARY: string = '#222222'
|
||||
const TEXT_SECONDARY: string = '#8A9099'
|
||||
const ACCENT: string = COLOR_PRIMARY
|
||||
@@ -103,6 +103,8 @@ export class FaceConsentStore {
|
||||
export struct FacePhotoConsent {
|
||||
onAgree: () => void = () => {}
|
||||
onRefuse: () => void = () => {}
|
||||
/** 用途说明行:默认试穿/发型场景口径;诊断报告等场景传入自己的文案。 */
|
||||
usageText: string = '用途:照片仅用于为你生成试穿/发型效果图,不用于识别你的身份,也不做其他用途的分析。'
|
||||
|
||||
@Builder
|
||||
ConsentLine(bullet: string, text: string) {
|
||||
@@ -128,9 +130,9 @@ export struct FacePhotoConsent {
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(TEXT_PRIMARY)
|
||||
Column({ space: 8 }) {
|
||||
this.ConsentLine('·', '用途:照片仅用于为你生成试穿/发型效果图,不用于识别你的身份,也不做其他用途的分析。')
|
||||
this.ConsentLine('·', '处理方:照片将上传至第三方 AI 图像服务完成本次生成,不会共享给其他方。')
|
||||
this.ConsentLine('·', '保存:原图在生成完成后即删除;你保存到相册的效果图可随时自行删除。')
|
||||
this.ConsentLine('·', this.usageText)
|
||||
this.ConsentLine('·', '处理方:照片将上传至美搭的图像服务完成本次生成,不会共享给其他方。')
|
||||
this.ConsentLine('·', '保存:上传的原图不会公开展示;你保存到相册的效果图可随时自行删除。')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* ImageViewer —— 试衣间全屏图片查看器(《美搭配与美学诊断-UI规范》§10.3)。
|
||||
*
|
||||
* 形态:全屏覆盖层,底 COLOR_BLACK_CAPSULE(#1A1A1A);顶部 ×关闭 + 「原图/效果图」
|
||||
* 页签(仅 hasResult 时显示页签,模特态只有 ×);图 Contain 宽 100%-32 居中,
|
||||
* 点图切换原图/效果——与页面预览共用父层 showOriginal(@Link 双向);底部
|
||||
* 「保存到相册」主按钮(走父层既有三态口径)+「再来一次」文字链(仅结果态)。
|
||||
* 关闭:点 × / 系统返回由父层 onBackPress 处理(viewerOpen 状态门闩)。
|
||||
*/
|
||||
|
||||
import {
|
||||
COLOR_BLACK_CAPSULE,
|
||||
COLOR_PRIMARY
|
||||
} from '../../core/Theme'
|
||||
|
||||
@Component
|
||||
export struct ImageViewer {
|
||||
@Link showOriginal: boolean // 与页面预览共用:false=效果图优先
|
||||
hasResult: boolean = false
|
||||
originalSrc: string = ''
|
||||
resultSrc: string = ''
|
||||
onSave: () => void = () => {}
|
||||
onRetry: () => void = () => {}
|
||||
onClose: () => void = () => {}
|
||||
|
||||
private currentSrc(): string {
|
||||
if (this.hasResult && !this.showOriginal) {
|
||||
return this.resultSrc
|
||||
}
|
||||
return this.originalSrc
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
Column() {
|
||||
// 顶栏 56:× 距左 16;页签仅结果态
|
||||
Stack({ alignContent: Alignment.Start }) {
|
||||
Text('×')
|
||||
.fontSize(24)
|
||||
.fontColor(Color.White)
|
||||
.width(40)
|
||||
.height(40)
|
||||
.textAlign(TextAlign.Center)
|
||||
.onClick(() => {
|
||||
this.onClose()
|
||||
})
|
||||
if (this.hasResult) {
|
||||
Row({ space: 24 }) {
|
||||
this.TabItem('原图', !this.showOriginal)
|
||||
this.TabItem('效果图', this.showOriginal)
|
||||
}
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height(56)
|
||||
|
||||
// 图:Contain 宽 100%-32 居中,点图切换
|
||||
Image(this.currentSrc())
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.objectFit(ImageFit.Contain)
|
||||
.margin({ left: 16, right: 16, top: 4, bottom: 8 })
|
||||
.onClick(() => {
|
||||
if (this.hasResult) {
|
||||
this.showOriginal = !this.showOriginal
|
||||
}
|
||||
})
|
||||
|
||||
// 底区:保存主按钮 + 再来一次(仅结果态显示两者;模特态留白)
|
||||
Column({ space: 10 }) {
|
||||
if (this.hasResult) {
|
||||
Button('保存到相册')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.borderRadius(24)
|
||||
.fontSize(16)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.onSave()
|
||||
})
|
||||
Text('再来一次')
|
||||
.fontSize(13)
|
||||
.fontColor(Color.White)
|
||||
.padding({ top: 4, bottom: 8 })
|
||||
.onClick(() => {
|
||||
this.onRetry()
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 32, right: 32, bottom: 20 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(COLOR_BLACK_CAPSULE)
|
||||
}
|
||||
|
||||
@Builder
|
||||
TabItem(label: string, active: boolean) {
|
||||
Column({ space: 4 }) {
|
||||
Text(label)
|
||||
.fontSize(13)
|
||||
.fontColor(active ? Color.White : '99FFFFFF')
|
||||
Row()
|
||||
.width('100%')
|
||||
.height(3)
|
||||
.borderRadius(2)
|
||||
.backgroundColor(active ? Color.White : Color.Transparent)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.showOriginal = label === '原图'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* NetworkBanner —— 统一网络/服务错误横幅(P0-6)。
|
||||
*
|
||||
* 用于「静默失败」的场景补位:数据没拉到但不值得整页错误态时,
|
||||
* 告诉用户数据可能不是最新的,并给「重试」出路。
|
||||
* 判定口径:isNetworkError(e)(core/ApiTypes,code=50000)。
|
||||
* 样式对齐 ErrorBanner:#FDF3E3 底 + #D46A0B 字。
|
||||
*/
|
||||
|
||||
@Component
|
||||
export struct NetworkBanner {
|
||||
onRetry: () => void = () => {}
|
||||
|
||||
build() {
|
||||
Row({ space: 8 }) {
|
||||
Text('网络不太顺畅,数据可能不是最新的')
|
||||
.fontSize(12)
|
||||
.fontColor('#D46A0B')
|
||||
.layoutWeight(1)
|
||||
Text('重试')
|
||||
.fontSize(12)
|
||||
.fontColor('#D46A0B')
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.onClick(() => {
|
||||
this.onRetry()
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
.borderRadius(10)
|
||||
.backgroundColor('#FDF3E3')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Segment —— 两枚胶囊分段控件(《美搭配与美学诊断-UI规范》§4/§7)。
|
||||
*
|
||||
* 使用方:发型页(女生/男生)、美学诊断填写页(男/女)。
|
||||
* 形态:容器 IMAGE_BG 圆角 18 内边距 2;选中段主色底白字圆角 16。
|
||||
*/
|
||||
|
||||
import { COLOR_PRIMARY, COLOR_IMAGE_BG, COLOR_TEXT_PRIMARY } from '../../core/Theme'
|
||||
|
||||
@Component
|
||||
export struct Segment {
|
||||
items: string[] = []
|
||||
@Prop selected: number = 0
|
||||
onSelect: (idx: number) => void = (_idx: number) => {}
|
||||
|
||||
build() {
|
||||
Row({ space: 0 }) {
|
||||
ForEach(this.items, (label: string, idx: number) => {
|
||||
Text(label)
|
||||
.fontSize(13)
|
||||
.fontColor(this.selected === idx ? Color.White : COLOR_TEXT_PRIMARY)
|
||||
.backgroundColor(this.selected === idx ? COLOR_PRIMARY : Color.Transparent)
|
||||
.padding({ left: 20, right: 20, top: 7, bottom: 7 })
|
||||
.borderRadius(16)
|
||||
.onClick(() => {
|
||||
if (this.selected !== idx) {
|
||||
this.onSelect(idx)
|
||||
}
|
||||
})
|
||||
}, (label: string) => label)
|
||||
}
|
||||
.padding(2)
|
||||
.borderRadius(18)
|
||||
.backgroundColor(COLOR_IMAGE_BG)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,13 @@ const CARD_PRIMARY_COLOR: string = COLOR_PRIMARY
|
||||
@Component
|
||||
export struct DimensionCard {
|
||||
dim: DimensionWire = EMPTY_DIMENSION
|
||||
/** 已提交的反馈判定('' = 未反馈;'accurate' / 'inaccurate' 高亮已选项,宿主回显用)。 */
|
||||
feedbackVerdict: string = ''
|
||||
/** 提交反馈(组件保持无网络):宿主调 POST /reports/{id}/feedback。 */
|
||||
onFeedback: (dimKey: string, verdict: string, correction: string) => void = () => {}
|
||||
@State ruleExpanded: boolean = false
|
||||
@State correctionOpen: boolean = false // 「不准」展开的行内修正输入
|
||||
@State correctionText: string = ''
|
||||
|
||||
private isSkipped(): boolean {
|
||||
return this.dim.status !== 'ok'
|
||||
@@ -63,6 +69,26 @@ export struct DimensionCard {
|
||||
return '本期未采集到足够信息'
|
||||
}
|
||||
|
||||
/** 反馈区可用条件(T-P3-21):仅 status=ok 且有 key 的维度卡。 */
|
||||
private feedbackable(): boolean {
|
||||
const k = this.dim.key
|
||||
return !this.isSkipped() && k !== undefined && k !== ''
|
||||
}
|
||||
|
||||
private dimKey(): string {
|
||||
return this.dim.key ?? ''
|
||||
}
|
||||
|
||||
private submitFeedback(verdict: string): void {
|
||||
if (verdict === 'inaccurate' && !this.correctionOpen) {
|
||||
// 点「不准」先展开行内修正输入(可选,≤100 字),再点「提交」才上报
|
||||
this.correctionOpen = true
|
||||
return
|
||||
}
|
||||
this.correctionOpen = false
|
||||
this.onFeedback(this.dimKey(), verdict, verdict === 'inaccurate' ? this.correctionText.trim() : '')
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 8 }) {
|
||||
@@ -129,6 +155,65 @@ export struct DimensionCard {
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
|
||||
// 结论反馈区(T-P3-21):仅 ok 维度;已选项高亮,再点另一项即改判
|
||||
if (this.feedbackable()) {
|
||||
Row({ space: 10 }) {
|
||||
Text('这个结论准吗?')
|
||||
.fontSize(12)
|
||||
.fontColor('#8A9099')
|
||||
.layoutWeight(1)
|
||||
Text('准 👍')
|
||||
.fontSize(12)
|
||||
.fontColor(this.feedbackVerdict === 'accurate' ? '#FFFFFF' : CARD_PRIMARY_COLOR)
|
||||
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
|
||||
.borderRadius(14)
|
||||
.backgroundColor(this.feedbackVerdict === 'accurate' ? CARD_PRIMARY_COLOR : '#F7E3EA')
|
||||
.onClick(() => {
|
||||
this.submitFeedback('accurate')
|
||||
})
|
||||
Text('不准 👎')
|
||||
.fontSize(12)
|
||||
.fontColor(this.feedbackVerdict === 'inaccurate' ? '#FFFFFF' : '#666666')
|
||||
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
|
||||
.borderRadius(14)
|
||||
.backgroundColor(this.feedbackVerdict === 'inaccurate' ? '#666666' : '#F5F5F7')
|
||||
.onClick(() => {
|
||||
this.submitFeedback('inaccurate')
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.margin({ top: 4 })
|
||||
|
||||
// 「不准」的行内修正输入(可选,≤100 字)
|
||||
if (this.correctionOpen) {
|
||||
TextArea({ text: this.correctionText, placeholder: '你觉得应该是?(可选)' })
|
||||
.width('100%')
|
||||
.height(72)
|
||||
.fontSize(13)
|
||||
.maxLength(100)
|
||||
.backgroundColor('#F5F5F7')
|
||||
.borderRadius(8)
|
||||
.onChange((v: string) => {
|
||||
this.correctionText = v
|
||||
})
|
||||
Row() {
|
||||
Blank()
|
||||
Text('提交')
|
||||
.fontSize(13)
|
||||
.fontColor(Color.White)
|
||||
.padding({ left: 20, right: 20, top: 6, bottom: 6 })
|
||||
.borderRadius(16)
|
||||
.backgroundColor(CARD_PRIMARY_COLOR)
|
||||
.onClick(() => {
|
||||
this.onFeedback(this.dimKey(), 'inaccurate', this.correctionText.trim())
|
||||
this.correctionOpen = false
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* FormRow —— 表单行(《美搭配与美学诊断-UI规范》§6/§7)。
|
||||
*
|
||||
* 使用方:美学诊断填写页(性别/出生日期/身高/体重/职业);
|
||||
* ReportCreatePage 后续可迁移复用。
|
||||
* 形态:行高 52(标签 14 左,值 14 右 + › 箭头 COLOR_TEXT_DISABLED),
|
||||
* 行间分隔线 0.5vp COLOR_DIVIDER;值未填时显示占位文案(Secondary)。
|
||||
* 行下可选错误提示(12 COLOR_ERROR)。
|
||||
* 通过 rowContent 插槽可塞入自定义控件(分段/标签组),此时 useCustomContent 置 true。
|
||||
*/
|
||||
|
||||
import {
|
||||
COLOR_TEXT_PRIMARY,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
COLOR_TEXT_DISABLED,
|
||||
COLOR_DIVIDER,
|
||||
COLOR_ERROR
|
||||
} from '../../core/Theme'
|
||||
|
||||
@Component
|
||||
export struct FormRow {
|
||||
/** 行标签(左侧)。 */
|
||||
label: string = ''
|
||||
/** 行值(右侧文本);空串时显示 placeholder。@Prop:父层选择器回调改 @State 后本行必须随之刷新。 */
|
||||
@Prop value: string = ''
|
||||
/** 值未填时的占位文案。 */
|
||||
placeholder: string = '请选择'
|
||||
/** 行下错误提示(12 COLOR_ERROR);空串不显示。@Prop:随父层校验态刷新。 */
|
||||
@Prop errorText: string = ''
|
||||
/** 是否显示右侧 › 箭头。 */
|
||||
showArrow: boolean = true
|
||||
/** 是否显示行底分隔线(最后一行置 false)。 */
|
||||
showDivider: boolean = true
|
||||
/** 使用 rowContent 插槽替代「值文本+箭头」。 */
|
||||
useCustomContent: boolean = false
|
||||
/** 插槽内容右对齐(标签组换行等场景置 false 改左对齐)。 */
|
||||
customAlignEnd: boolean = true
|
||||
/** 自定义行内容插槽(在父组件作用域执行,可引用父组件 @State)。 */
|
||||
@BuilderParam rowContent: () => void = this.doNothing
|
||||
/** 整行点击(打开选择器等)。 */
|
||||
onRowClick: () => void = () => {}
|
||||
|
||||
@Builder
|
||||
doNothing() {
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Row({ space: 8 }) {
|
||||
Text(this.label)
|
||||
.fontSize(14)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
|
||||
if (this.useCustomContent) {
|
||||
Column() {
|
||||
this.rowContent()
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(this.customAlignEnd ? HorizontalAlign.End : HorizontalAlign.Start)
|
||||
} else {
|
||||
Text(this.value === '' ? this.placeholder : this.value)
|
||||
.fontSize(14)
|
||||
.fontColor(this.value === '' ? COLOR_TEXT_SECONDARY : COLOR_TEXT_PRIMARY)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(1)
|
||||
.layoutWeight(1)
|
||||
if (this.showArrow) {
|
||||
Text('›')
|
||||
.fontSize(16)
|
||||
.fontColor(COLOR_TEXT_DISABLED)
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.constraintSize({ minHeight: 52 })
|
||||
.padding({ top: 6, bottom: 6 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.onRowClick()
|
||||
})
|
||||
|
||||
if (this.errorText !== '') {
|
||||
Text(this.errorText)
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.width('100%')
|
||||
.padding({ bottom: 6 })
|
||||
}
|
||||
|
||||
if (this.showDivider) {
|
||||
Divider()
|
||||
.strokeWidth(0.5)
|
||||
.color(COLOR_DIVIDER)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -264,12 +311,6 @@ export function randomHex(len: number): string {
|
||||
return out
|
||||
}
|
||||
|
||||
/** 模拟照片对象键:report_photo/{userId}/{随机}.jpg(V1 骨架,COS 直传未接入) */
|
||||
export function buildMockPhotoKey(userId: string): string {
|
||||
const uid = userId === '' ? 'anonymous' : userId
|
||||
return `report_photo/${uid}/${randomHex(12)}.jpg`
|
||||
}
|
||||
|
||||
/** 32 维按 status 分两组:已分析(ok)与未覆盖(skipped,含 status 缺失的兜底) */
|
||||
export function splitDimensions(dims: DimensionWire[]): DimensionGroups {
|
||||
const groups: DimensionGroups = { analyzed: [], skipped: [] }
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* StepBar —— 横向步骤条(《美搭配与美学诊断-UI规范》§5/§7)。
|
||||
*
|
||||
* 使用方:美学诊断落地页「如何生成你的报告」四步流程(测一测/分析/生成/报告)。
|
||||
* 形态:圆点 28vp(当前主色底白字 / 未选 IMAGE_BG 底 Secondary 字),
|
||||
* 连线 2vp COLOR_DIVIDER,标签 11 居中,四步等宽。
|
||||
*/
|
||||
|
||||
import {
|
||||
COLOR_PRIMARY,
|
||||
COLOR_IMAGE_BG,
|
||||
COLOR_TEXT_PRIMARY,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
COLOR_DIVIDER
|
||||
} from '../../core/Theme'
|
||||
|
||||
@Component
|
||||
export struct StepBar {
|
||||
/** 步骤标签(自左向右)。 */
|
||||
steps: string[] = []
|
||||
/** 当前进度下标:下标 ≤ current 的步骤圆点为主色底,其余为灰底。 */
|
||||
current: number = 0
|
||||
|
||||
build() {
|
||||
Row({ space: 0 }) {
|
||||
ForEach(this.steps, (label: string, idx: number) => {
|
||||
// 每步 + 右侧连线占一个等宽格,连线与圆点中心对齐(行高=圆点28)
|
||||
Row({ space: 0 }) {
|
||||
Column({ space: 6 }) {
|
||||
Text(`${idx + 1}`)
|
||||
.width(28)
|
||||
.height(28)
|
||||
.borderRadius(14)
|
||||
.backgroundColor(idx <= this.current ? COLOR_PRIMARY : COLOR_IMAGE_BG)
|
||||
.fontColor(idx <= this.current ? Color.White : COLOR_TEXT_SECONDARY)
|
||||
.fontSize(13)
|
||||
.textAlign(TextAlign.Center)
|
||||
Text(label)
|
||||
.fontSize(11)
|
||||
.fontColor(idx <= this.current ? COLOR_TEXT_PRIMARY : COLOR_TEXT_SECONDARY)
|
||||
.maxLines(1)
|
||||
}
|
||||
.width(64)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
|
||||
if (idx < this.steps.length - 1) {
|
||||
Row() {
|
||||
Divider()
|
||||
.strokeWidth(2)
|
||||
.color(COLOR_DIVIDER)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.height(28)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.margin({ left: 4, right: 4 })
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}, (label: string) => label)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
* - 不依赖 queueMode:官方 API 文档(0=flush)与 Codelabs(0=排队)说法
|
||||
* 互相矛盾,新朗读前显式 engine.stop() 打断,语义自持
|
||||
* - 播报状态回调 onStateChanged:页面据此复位 🔊 图标
|
||||
* - V3:speak 前 sanitizeForSpeech 净化——不念 Markdown/英文标点/表情符号
|
||||
* (中文停顿标点保留做自然停顿)
|
||||
*
|
||||
* 模型:offline 中文引擎(online: 1,系统内置);模拟器通常无此服务,须真机。
|
||||
* 日志 tag MEIDA_TTS:hdc shell hilog | grep MEIDA_TTS
|
||||
@@ -18,6 +20,18 @@ import { textToSpeech } from '@kit.CoreSpeechKit'
|
||||
const SEGMENT_MAX_CHARS: number = 400
|
||||
const SEGMENT_FALLBACK_MS: number = 150000
|
||||
|
||||
/**
|
||||
* 朗读净化(V3):剥掉会被引擎念出来的符号——Markdown 标记(**、#、- 列表符)、
|
||||
* 英文标点、表情等,只留中英文、数字与中文停顿标点(,。;:!?、……靠它们做自然停顿,
|
||||
* 引擎不会逐字念)。顾问回答被要求不写 Markdown(agent_chat v1),这里是双保险。
|
||||
*/
|
||||
function sanitizeForSpeech(text: string): string {
|
||||
return text
|
||||
.replace(/[^一-龥a-zA-Z0-9\s。,、;:!?]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** 播报状态变化回调(speaking=false 表示本轮朗读完全结束或被停止)。 */
|
||||
export type TtsStateChanged = (speaking: boolean) => void
|
||||
|
||||
@@ -78,7 +92,7 @@ export class TtsEngine {
|
||||
* 由调用方决定是否提示(自动朗读静默、手动点击提示)。
|
||||
*/
|
||||
async speak(text: string): Promise<void> {
|
||||
const trimmed: string = text.trim()
|
||||
const trimmed: string = sanitizeForSpeech(text)
|
||||
if (trimmed.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -38,3 +38,11 @@ const AUTH_ERROR_CODES: number[] = [40100, 40101, 40102, 40103, 40104, 40105]
|
||||
export function isAuthError(e: Object): boolean {
|
||||
return e instanceof ApiError && AUTH_ERROR_CODES.includes(e.code)
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否网络/服务类错误(P0-6):ApiClient 里网络异常与网关无信封错误都收敛到
|
||||
* 50000(INTERNAL_ERROR 段)。这类错误「重试」有意义,错误态给 NetworkBanner。
|
||||
*/
|
||||
export function isNetworkError(e: Object): boolean {
|
||||
return e instanceof ApiError && e.code === 50000
|
||||
}
|
||||
|
||||
@@ -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,75 @@
|
||||
/**
|
||||
* BuiltInCloth —— 「精选衣橱」内置服装库(rawfile/cloth,需求 v2 §3.3 赴宴精选启用)。
|
||||
*
|
||||
* - 素材 30 件:本地 cloth_pic 15 + 网络精选 15,统一 900px JPEG + manifest.json
|
||||
* (构建期离线打包,无网络请求;文件名→品类→名称见 rawfile/cloth/manifest.json);
|
||||
* - 展示:虚拟 WardrobeItem(id='b_<file>',uri=''),ClothCard 走 $rawfile 渲染;
|
||||
* - 生成:getRawFileContent 直读字节 → Base64(素材已标准化,无需二次压缩)。
|
||||
*/
|
||||
|
||||
import { util } from '@kit.ArkTS'
|
||||
import { ContextHolder } from './ContextHolder'
|
||||
import { WardrobeItem } from './WardrobeStore'
|
||||
|
||||
interface BuiltInEntry {
|
||||
file: string
|
||||
category: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface BuiltInWire {
|
||||
items: BuiltInEntry[]
|
||||
}
|
||||
|
||||
const MANIFEST_PATH: string = 'cloth/manifest.json'
|
||||
const ID_PREFIX: string = 'b_'
|
||||
|
||||
export class BuiltInCloth {
|
||||
private static items: WardrobeItem[] = []
|
||||
private static loaded: boolean = false
|
||||
|
||||
/** 是否内置条目 id('b_' 前缀)。 */
|
||||
static isBuiltin(id: string): boolean {
|
||||
return id.startsWith(ID_PREFIX)
|
||||
}
|
||||
|
||||
/** 全部内置条目(虚拟 WardrobeItem;加载失败为空数组)。 */
|
||||
static async load(): Promise<WardrobeItem[]> {
|
||||
if (BuiltInCloth.loaded) {
|
||||
return BuiltInCloth.items
|
||||
}
|
||||
try {
|
||||
const rm = ContextHolder.get().resourceManager
|
||||
const data = await rm.getRawFileContent(MANIFEST_PATH)
|
||||
const text = util.TextDecoder.create('utf-8').decodeToString(data)
|
||||
const wire = JSON.parse(text) as BuiltInWire
|
||||
const out: WardrobeItem[] = []
|
||||
for (const e of wire.items) {
|
||||
if (typeof e.file !== 'string' || e.file.length === 0) {
|
||||
continue
|
||||
}
|
||||
const it: WardrobeItem = {
|
||||
id: ID_PREFIX + e.file,
|
||||
uri: '',
|
||||
category: e.category,
|
||||
name: e.name,
|
||||
createdAt: 0
|
||||
}
|
||||
out.push(it)
|
||||
}
|
||||
BuiltInCloth.items = out
|
||||
} catch (_e) {
|
||||
BuiltInCloth.items = [] // manifest 缺失/损坏:精选页展示空态,不阻断
|
||||
}
|
||||
BuiltInCloth.loaded = true
|
||||
return BuiltInCloth.items
|
||||
}
|
||||
|
||||
/** 内置图字节直读 → Base64(900px JPEG 已标准化,直接可用)。 */
|
||||
static async fileBase64(file: string): Promise<string> {
|
||||
const rm = ContextHolder.get().resourceManager
|
||||
const data = await rm.getRawFileContent(`cloth/${file}`)
|
||||
const helper = new util.Base64Helper()
|
||||
return helper.encodeToStringSync(data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* DailyLimiter —— 试穿/试戴端侧每日兜底限次(P0-5,与任务换额度并行)。
|
||||
*
|
||||
* - 免费档:tryon / hairstyle 各 3 次/天(产品方案 §3.2),按自然日(本地时区
|
||||
* yyyy-MM-dd)重置;持久化 preferences(meida_daily_limit),结构
|
||||
* {"yyyy-MM-dd": {"tryon": n, "hairstyle": n, "tryonBonus": n, "hairstyleBonus": n}},
|
||||
* 旧日期整串替换不累积;bonus 缺省 0(迭代 2 旧数据读不到按 0 兼容);
|
||||
* - 奖励加成(迭代 3 修正):服务端配额拉取成功时 setBonus(key, bonus_granted)
|
||||
* 把当日上限抬为 3 + max(bonus,0)——「做任务多换几次」在试穿/试戴页真正兑现;
|
||||
* 服务端拉取失败保持 bonus=0(兜底 3 次不变);
|
||||
* - 口径「失败不计数」:调用点先 peek 判断放行,生成成功后 tryConsume 记账,
|
||||
* 失败分支不调 tryConsume;
|
||||
* - 端侧只是兜底/体验层(服务端配额是权威):剩余次数取 min(服务端剩余,
|
||||
* 端侧剩余),页面自行合并;preferences 不可用时退化为内存态,不阻断功能。
|
||||
*/
|
||||
|
||||
import preferences from '@ohos.data.preferences'
|
||||
import { ContextHolder } from './ContextHolder'
|
||||
|
||||
export type DailyLimitKey = 'tryon' | 'hairstyle'
|
||||
|
||||
interface DailyCounts {
|
||||
tryon: number
|
||||
hairstyle: number
|
||||
tryonBonus: number
|
||||
hairstyleBonus: number
|
||||
}
|
||||
|
||||
/** 免费档每日上限(产品方案 §3.2)。 */
|
||||
const DAILY_LIMIT: number = 3
|
||||
const PREF_NAME: string = 'meida_daily_limit'
|
||||
const KEY_COUNTS: string = 'counts'
|
||||
|
||||
export class DailyLimiter {
|
||||
private static counts: DailyCounts = { tryon: 0, hairstyle: 0, tryonBonus: 0, hairstyleBonus: 0 }
|
||||
private static day: string = ''
|
||||
private static prefs: preferences.Preferences | undefined
|
||||
|
||||
/** hydrate preferences → 内存;幂等。日期漂移(跨零点)在每次读取时检查。 */
|
||||
static async init(): Promise<void> {
|
||||
if (DailyLimiter.prefs !== undefined) {
|
||||
DailyLimiter.ensureToday()
|
||||
return
|
||||
}
|
||||
try {
|
||||
DailyLimiter.prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME })
|
||||
const raw = await DailyLimiter.prefs.get(KEY_COUNTS, '') as string
|
||||
if (raw !== '') {
|
||||
const parsed = JSON.parse(raw) as Record<string, DailyCounts>
|
||||
const today: string = DailyLimiter.today()
|
||||
const todayCounts: DailyCounts | undefined = parsed[today]
|
||||
if (todayCounts !== undefined) {
|
||||
DailyLimiter.day = today
|
||||
DailyLimiter.counts = {
|
||||
tryon: DailyLimiter.safeNum(todayCounts.tryon),
|
||||
hairstyle: DailyLimiter.safeNum(todayCounts.hairstyle),
|
||||
tryonBonus: DailyLimiter.safeNum(todayCounts.tryonBonus),
|
||||
hairstyleBonus: DailyLimiter.safeNum(todayCounts.hairstyleBonus)
|
||||
}
|
||||
} else {
|
||||
DailyLimiter.reset()
|
||||
}
|
||||
} else {
|
||||
DailyLimiter.reset()
|
||||
}
|
||||
} catch (_e) {
|
||||
DailyLimiter.reset()
|
||||
}
|
||||
}
|
||||
|
||||
/** 剩余次数(含自然日重置检查):上限 = DAILY_LIMIT + 当日 bonus。 */
|
||||
static remaining(key: DailyLimitKey): number {
|
||||
DailyLimiter.ensureToday()
|
||||
return Math.max(DAILY_LIMIT + DailyLimiter.getBonus(key) - DailyLimiter.getCount(key), 0)
|
||||
}
|
||||
|
||||
/** 同 remaining,语义上强调「只看不记」:生成前放行判断用。 */
|
||||
static peek(key: DailyLimitKey): number {
|
||||
return DailyLimiter.remaining(key)
|
||||
}
|
||||
|
||||
/** 设置当日奖励加成(服务端 quota.bonus_granted):上限变 3 + max(bonus,0),随当天结构落盘。 */
|
||||
static setBonus(key: DailyLimitKey, bonus: number): void {
|
||||
DailyLimiter.ensureToday()
|
||||
const v: number = Math.max(Math.floor(bonus), 0)
|
||||
if (key === 'tryon') {
|
||||
DailyLimiter.counts.tryonBonus = v
|
||||
} else {
|
||||
DailyLimiter.counts.hairstyleBonus = v
|
||||
}
|
||||
DailyLimiter.persist()
|
||||
}
|
||||
|
||||
/** 记一次成功消耗;返回是否记上(满额返回 false,调用点不应走到这)。 */
|
||||
static tryConsume(key: DailyLimitKey): boolean {
|
||||
DailyLimiter.ensureToday()
|
||||
if (DailyLimiter.remaining(key) <= 0) {
|
||||
return false
|
||||
}
|
||||
if (key === 'tryon') {
|
||||
DailyLimiter.counts.tryon += 1
|
||||
} else {
|
||||
DailyLimiter.counts.hairstyle += 1
|
||||
}
|
||||
DailyLimiter.persist()
|
||||
return true
|
||||
}
|
||||
|
||||
/** 本地时区自然日 key(yyyy-MM-dd)。 */
|
||||
static today(): string {
|
||||
const d: Date = new Date()
|
||||
const m: string = `${d.getMonth() + 1}`.padStart(2, '0')
|
||||
const day: string = `${d.getDate()}`.padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${day}`
|
||||
}
|
||||
|
||||
/** 跨零点惰性重置:每次读/写前对齐当天。 */
|
||||
private static ensureToday(): void {
|
||||
const today: string = DailyLimiter.today()
|
||||
if (DailyLimiter.day !== today) {
|
||||
DailyLimiter.reset()
|
||||
}
|
||||
}
|
||||
|
||||
/** 计数读取(ArkTS 不允许字段索引访问,显式分派)。 */
|
||||
private static getCount(key: DailyLimitKey): number {
|
||||
return key === 'tryon' ? DailyLimiter.counts.tryon : DailyLimiter.counts.hairstyle
|
||||
}
|
||||
|
||||
/** 加成读取(同上,显式分派)。 */
|
||||
private static getBonus(key: DailyLimitKey): number {
|
||||
return key === 'tryon' ? DailyLimiter.counts.tryonBonus : DailyLimiter.counts.hairstyleBonus
|
||||
}
|
||||
|
||||
private static reset(): void {
|
||||
DailyLimiter.day = DailyLimiter.today()
|
||||
DailyLimiter.counts = { tryon: 0, hairstyle: 0, tryonBonus: 0, hairstyleBonus: 0 }
|
||||
}
|
||||
|
||||
private static safeNum(v: number): number {
|
||||
return typeof v === 'number' && v >= 0 ? Math.floor(v) : 0
|
||||
}
|
||||
|
||||
/** 只存当天结构(旧日期整串替换)。尽力写盘,失败不阻断。 */
|
||||
private static persist(): void {
|
||||
try {
|
||||
if (DailyLimiter.prefs === undefined) {
|
||||
return
|
||||
}
|
||||
const data: Record<string, DailyCounts> = {}
|
||||
data[DailyLimiter.day] = DailyLimiter.counts
|
||||
DailyLimiter.prefs.put(KEY_COUNTS, JSON.stringify(data))
|
||||
DailyLimiter.prefs.flush()
|
||||
} catch (_e) {
|
||||
// 内存态已生效,持久化失败下次启动重置即可
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -41,7 +42,7 @@ const LONG_EDGE: number = 1600
|
||||
const JPEG_QUALITY: number = 85
|
||||
const IMG_DOMAIN: number = 0x0001
|
||||
const IMG_TAG: string = 'MEIDA_IMG'
|
||||
const SELECT_TIMEOUT_MS: number = 60000 // 相册选图兜底:系统弹窗最多等 60s
|
||||
const SELECT_TIMEOUT_MS: number = 120000 // 相册选图兜底:冷启动+用户浏览常超 60s(超时后系统浮层成孤儿,点击全失效)
|
||||
const CAMERA_TIMEOUT_MS: number = 120000 // 拍照兜底:给足取景与确认时间
|
||||
|
||||
export class ImageCodec {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* TaskBonusState —— 「做任务换额度」已领标记的共享读写(P0-4,迭代 3 抽取)。
|
||||
*
|
||||
* 从 TaskBonusPanel 抽出的公共逻辑:preferences meida_task_bonus / claims
|
||||
* ({task_key: ISO日期|'done'},与迭代 2 同文件同结构,不迁移旧数据)。
|
||||
* 共享动机:报告页反馈成功会自动 claim report_feedback——若已领态只活在
|
||||
* 面板里,用户在报告页领了、任务面板里还显示可领。
|
||||
*
|
||||
* 口径:day 任务按本地 ISO 日期(yyyy-MM-dd)判定;lifetime 任务领过即 'done'。
|
||||
* 本地状态永远以服务端口径为准:claim 返回 40910(本周期已领)时各调用点
|
||||
* 都要 markClaimed 同步。
|
||||
*/
|
||||
|
||||
import preferences from '@ohos.data.preferences'
|
||||
import { ContextHolder } from './ContextHolder'
|
||||
|
||||
export const PERIOD_DAY: string = 'day'
|
||||
export const PERIOD_LIFETIME: string = 'lifetime'
|
||||
export const CLAIMED_DONE: string = 'done' // lifetime 任务的已领标记值
|
||||
|
||||
const PREF_NAME: string = 'meida_task_bonus'
|
||||
const KEY_CLAIMS: string = 'claims'
|
||||
|
||||
/** 本地 ISO 日期戳(yyyy-MM-dd):日任务的已领判定基准。 */
|
||||
export function todayStamp(): string {
|
||||
const now = new Date()
|
||||
const m: string = `${now.getMonth() + 1}`.padStart(2, '0')
|
||||
const d: string = `${now.getDate()}`.padStart(2, '0')
|
||||
return `${now.getFullYear()}-${m}-${d}`
|
||||
}
|
||||
|
||||
export class TaskBonusState {
|
||||
private static claims: Record<string, string> = {}
|
||||
private static prefs: preferences.Preferences | undefined = undefined
|
||||
|
||||
/** preferences → 内存(幂等;失败退化为空记录,功能仍可用)。 */
|
||||
static async init(): Promise<void> {
|
||||
if (TaskBonusState.prefs !== undefined) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
TaskBonusState.prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME })
|
||||
const raw = await TaskBonusState.prefs.get(KEY_CLAIMS, '') as string
|
||||
if (raw !== '') {
|
||||
const parsed = JSON.parse(raw) as Record<string, string>
|
||||
TaskBonusState.claims = typeof parsed === 'object' && parsed !== null ? parsed : {}
|
||||
}
|
||||
} catch (_e) {
|
||||
TaskBonusState.claims = {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否本周期已领(读内存;调用前先 init)。 */
|
||||
static isClaimed(taskKey: string, period: string): boolean {
|
||||
const v: string = TaskBonusState.claims[taskKey] ?? ''
|
||||
if (period === PERIOD_LIFETIME) {
|
||||
return v === CLAIMED_DONE
|
||||
}
|
||||
return v === todayStamp()
|
||||
}
|
||||
|
||||
/** 标记已领并尽力写盘;持久化失败不阻断(服务端 40910 会兜底同步)。 */
|
||||
static async markClaimed(taskKey: string, period: string): Promise<void> {
|
||||
TaskBonusState.claims[taskKey] = period === PERIOD_LIFETIME ? CLAIMED_DONE : todayStamp()
|
||||
try {
|
||||
if (TaskBonusState.prefs === undefined) {
|
||||
TaskBonusState.prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME })
|
||||
}
|
||||
await TaskBonusState.prefs.put(KEY_CLAIMS, JSON.stringify(TaskBonusState.claims))
|
||||
await TaskBonusState.prefs.flush()
|
||||
} catch (_e) {
|
||||
// 内存态已生效;持久化失败最坏下次启动再领一次
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,28 @@ export const COLOR_TEXT_PRIMARY: string = '#222222'
|
||||
|
||||
/** 次级文字(返回控件、辅助说明统一用此色) */
|
||||
export const COLOR_TEXT_SECONDARY: string = '#8A9099'
|
||||
|
||||
/** 禁用文字/占位箭头(《美搭配与美学诊断-UI规范》§1 收口) */
|
||||
export const COLOR_TEXT_DISABLED: string = '#C2C7CE'
|
||||
|
||||
/** 页面底色(原各页散落 #F5F6F8 收口) */
|
||||
export const COLOR_PAGE_BG: string = '#F5F6F8'
|
||||
|
||||
/** 分隔线 / chip 描边 */
|
||||
export const COLOR_DIVIDER: string = '#E5E7EB'
|
||||
|
||||
/** 图片占位底 / 未选中段底 */
|
||||
export const COLOR_IMAGE_BG: string = '#EDEFF3'
|
||||
|
||||
/** 「试穿」黑胶囊与品类 chip 选中态(仅试衣间域) */
|
||||
export const COLOR_BLACK_CAPSULE: string = '#1A1A1A'
|
||||
|
||||
/** 图上角标底(模特/删除/原图|效果图):黑 60% 透明 */
|
||||
export const COLOR_BADGE_BG: string = '#99000000'
|
||||
|
||||
/** 美学诊断落地页 hero 渐变(仅落地页使用,其余页面禁紫) */
|
||||
export const COLOR_DIAG_HERO_TOP: string = '#7C4DFF'
|
||||
export const COLOR_DIAG_HERO_BOTTOM: string = '#5E35B1'
|
||||
|
||||
/** 错误文案(原 HairDesignPage 散落 #C0392B 收口) */
|
||||
export const COLOR_ERROR: string = '#C0392B'
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Tracker —— 端侧埋点(P0-5 / FR-SYS-06),单 API:Tracker.track(eventName, props?)。
|
||||
*
|
||||
* - 事件名在调用点写死字面量;内部用后端同款正则 ^[a-z][a-z0-9_.]{1,63}$
|
||||
* 校验,不合法丢弃并 console.warn(不上报脏事件名,后端 40020 是整批 fail-close);
|
||||
* - 队列:内存数组 + preferences(meida_tracker / queue)持久化,启动时 hydrate;
|
||||
* track 时补 client_ts(ISO8601);队列 ≥20 条立即 flush,否则 30s 定时器节流;
|
||||
* EntryAbility.onBackground 尽力 flush 一次;
|
||||
* - 上报:POST /api/v1/events/batch(只走 ApiClient)。成功 / 40020(内容违规
|
||||
* 整批拒)/ 40100(未登录短路)→ 整批丢弃;网络类其他失败 → 保留队列,
|
||||
* 上限 200 条超出丢最旧;
|
||||
* - 埋点永不影响业务:内部所有异常吞掉;
|
||||
* - 隐私纪律:props 只允许页面名、quota_key、task_key、错误码、耗时等低维
|
||||
* 枚举/数值;禁止用户输入原文、报告内容、图片 key、设备号。phone/token/
|
||||
* authorization 键(大小写不敏感)在入队前强制剔除(后端同款黑名单兜底)。
|
||||
*/
|
||||
|
||||
import preferences from '@ohos.data.preferences'
|
||||
import { ApiClient } from './ApiClient'
|
||||
import { ApiError } from './ApiTypes'
|
||||
import { ContextHolder } from './ContextHolder'
|
||||
|
||||
/** 单条事件(字段名对齐后端 EventItem schema)。 */
|
||||
export interface TrackEvent {
|
||||
event_name: string
|
||||
props: Record<string, string | number | boolean>
|
||||
client_ts: string
|
||||
}
|
||||
|
||||
const PREF_NAME = 'meida_tracker'
|
||||
const KEY_QUEUE = 'queue'
|
||||
const EVENT_NAME_RE: RegExp = /^[a-z][a-z0-9_.]{1,63}$/
|
||||
const FORBIDDEN_PROP_KEYS: string[] = ['phone', 'token', 'authorization']
|
||||
const FLUSH_THRESHOLD: number = 20 // 达到即 flush
|
||||
const BATCH_SIZE: number = 50 // 后端单批上限
|
||||
const QUEUE_CAP: number = 200 // 超出丢最旧
|
||||
const FLUSH_INTERVAL_MS: number = 30000
|
||||
/** 后端对违规批次 fail-close:40020 整批拒;未登录短路 40100 同样弃批。 */
|
||||
const DROP_BATCH_CODES: number[] = [40020, 40100]
|
||||
|
||||
export class Tracker {
|
||||
private static queue: TrackEvent[] = []
|
||||
private static prefs: preferences.Preferences | undefined
|
||||
private static flushing: boolean = false
|
||||
private static timerStarted: boolean = false
|
||||
|
||||
/** hydrate 持久化队列 + 启 30s 节流定时器。幂等;失败不阻断 App 启动。 */
|
||||
static async init(): Promise<void> {
|
||||
try {
|
||||
if (Tracker.prefs === undefined) {
|
||||
Tracker.prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME })
|
||||
const raw = await Tracker.prefs.get(KEY_QUEUE, '') as string
|
||||
if (raw !== '') {
|
||||
const parsed = JSON.parse(raw) as TrackEvent[]
|
||||
Tracker.queue = Array.isArray(parsed)
|
||||
? parsed.filter((e: TrackEvent): boolean => typeof e.event_name === 'string')
|
||||
: []
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
Tracker.queue = []
|
||||
}
|
||||
if (!Tracker.timerStarted) {
|
||||
Tracker.timerStarted = true
|
||||
setInterval(() => {
|
||||
Tracker.flush()
|
||||
}, FLUSH_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** 唯一公开 API。事件名/props 不合规即丢弃,永不抛异常。 */
|
||||
static track(eventName: string, props?: Record<string, string | number | boolean>): void {
|
||||
try {
|
||||
if (!EVENT_NAME_RE.test(eventName)) {
|
||||
console.warn(`MEIDA_TRACK drop invalid event name: ${eventName}`)
|
||||
return
|
||||
}
|
||||
const event: TrackEvent = {
|
||||
event_name: eventName,
|
||||
props: Tracker.sanitizeProps(props),
|
||||
client_ts: new Date().toISOString()
|
||||
}
|
||||
Tracker.queue.push(event)
|
||||
while (Tracker.queue.length > QUEUE_CAP) {
|
||||
Tracker.queue.shift() // 上限 200:丢最旧
|
||||
}
|
||||
Tracker.persist()
|
||||
if (Tracker.queue.length >= FLUSH_THRESHOLD) {
|
||||
Tracker.flush()
|
||||
}
|
||||
} catch (_e) {
|
||||
// 埋点永不影响业务
|
||||
}
|
||||
}
|
||||
|
||||
/** 退后台尽力 flush(EntryAbility.onBackground 调用,不 await)。 */
|
||||
static flushOnBackground(): void {
|
||||
Tracker.flush()
|
||||
}
|
||||
|
||||
/** 逐批上报:丢批(成功/40020/40100)继续清下一批;网络失败保留剩余队列。 */
|
||||
private static async flush(): Promise<void> {
|
||||
if (Tracker.flushing || Tracker.queue.length === 0) {
|
||||
return
|
||||
}
|
||||
Tracker.flushing = true
|
||||
try {
|
||||
while (Tracker.queue.length > 0) {
|
||||
const batch: TrackEvent[] = Tracker.queue.slice(0, BATCH_SIZE)
|
||||
const sent: boolean = await Tracker.sendBatch(batch)
|
||||
if (!sent) {
|
||||
break
|
||||
}
|
||||
Tracker.queue.splice(0, batch.length)
|
||||
Tracker.persist()
|
||||
}
|
||||
} finally {
|
||||
Tracker.flushing = false
|
||||
}
|
||||
}
|
||||
|
||||
/** true=本批可丢弃(送达或明确被拒);false=网络类失败,保留重试。 */
|
||||
private static async sendBatch(batch: TrackEvent[]): Promise<boolean> {
|
||||
try {
|
||||
const eventsObj: Object = batch
|
||||
const body: Record<string, Object> = { 'events': eventsObj }
|
||||
await ApiClient.post<void>('/api/v1/events/batch', body)
|
||||
return true
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && DROP_BATCH_CODES.includes(e.code)) {
|
||||
console.warn(`MEIDA_TRACK drop batch, code=${e.code}`)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 剔除黑名单键(大小写不敏感):后端 40020 是整批 fail-close,脏键不能上船。 */
|
||||
private static sanitizeProps(
|
||||
props: Record<string, string | number | boolean> | undefined
|
||||
): Record<string, string | number | boolean> {
|
||||
const out: Record<string, string | number | boolean> = {}
|
||||
if (props === undefined) {
|
||||
return out
|
||||
}
|
||||
const keys: string[] = Object.keys(props)
|
||||
for (const k of keys) {
|
||||
if (FORBIDDEN_PROP_KEYS.includes(k.toLowerCase())) {
|
||||
console.warn(`MEIDA_TRACK drop forbidden prop key: ${k}`)
|
||||
continue
|
||||
}
|
||||
out[k] = props[k]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 尽力写盘:失败不阻断(内存队列仍在,下次启动重读旧值)。 */
|
||||
private static persist(): void {
|
||||
try {
|
||||
if (Tracker.prefs === undefined) {
|
||||
return // init 未完成:内存队列优先,持久化等下次 track
|
||||
}
|
||||
Tracker.prefs.put(KEY_QUEUE, JSON.stringify(Tracker.queue))
|
||||
Tracker.prefs.flush()
|
||||
} catch (_e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,51 @@
|
||||
*/
|
||||
|
||||
import http from '@ohos.net.http'
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit'
|
||||
import { util } from '@kit.ArkTS'
|
||||
import { ApiError } from './ApiTypes'
|
||||
|
||||
/** 手工 multipart 的一个字段(image 字节或文本)。 */
|
||||
interface HairFormPart {
|
||||
name: string
|
||||
filename?: string
|
||||
contentType?: string
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
/** 自定义边界(报文与请求头共用)。 */
|
||||
const HAIR_BOUNDARY: string = '----MeidaHairForm7MA4YWxkTrZu0gW'
|
||||
|
||||
/**
|
||||
* 发型性别映射:服务端 hairstyles 接口无性别字段,60 款由缩略图逐张视觉分类。
|
||||
* 2026-09-11 全量重分类(男 30 / 女 30,无 unknown):缩略图按男模/女模实拍渲染,
|
||||
* 以模特呈现为准(此前 2026-09-02 版抽检不准,男/女 Tab 互串 18 款,用户反馈后重分)。
|
||||
* 新增未知 id 返回 unknown(前端两个 Tab 都展示)。
|
||||
*/
|
||||
const MALE_HAIRSTYLE_IDS: Set<string> = new Set<string>([
|
||||
'1953267161121464322', '1953267288284372994', '1953270724765720577', '1953271922151432194',
|
||||
'1953272913265467394', '1953288800932503554', '1954536242109784066', '1954536297776586754',
|
||||
'1954536402516746241', '1954536893304840194', '1954536956919848962', '1954537191729569793',
|
||||
'1980312945301954561', '1980314209322573825', '1980314710101499906', '1980314764421931009',
|
||||
'1980314868713299969', '1980367781510815746', '1980367800787836929', '1980367852847538177',
|
||||
'1980367968627105793', '1980368109866098690', '1980368205752082434', '1980368465631158274',
|
||||
'1980368588293578754', '1981177378714796034', '1981177477423546370', '1981177594750812162',
|
||||
'1981177742742634497', '1981177931859607554'
|
||||
])
|
||||
const FEMALE_HAIRSTYLE_IDS: Set<string> = new Set<string>([
|
||||
'1953270523594317826', '1954537022875279362', '1954537085882114050', '1954537709436706817',
|
||||
'1954537743532204034', '1954537961069780994', '1954538119216013313', '1954538156499181569',
|
||||
'1954538259330932738', '1954538335306555393', '1980313052302843905', '1980313166576656385',
|
||||
'1980313210109337601', '1980313433502162946', '1980313576506957825', '1980313740403580930',
|
||||
'1980313819915001858', '1980314032369082369', '1980314290419441665', '1980314381058351106',
|
||||
'1980314477720281090', '1980315179934851074', '1980315238260842497', '1980361636742209537',
|
||||
'1980367742952579074', '1980368368847593473', '1980413320612847618', '1980415346071609345',
|
||||
'1980415897056354306', '1981177324700549122'
|
||||
])
|
||||
|
||||
const TRYON_DOMAIN: number = 0x0200
|
||||
const TRYON_TAG: string = 'MEIDA_TRYON'
|
||||
|
||||
export interface Hairstyle {
|
||||
id: string
|
||||
name: string
|
||||
@@ -45,6 +88,7 @@ interface ChangeClothWire {
|
||||
}
|
||||
|
||||
const BASE: string = 'https://meida.xiangsilian.com'
|
||||
const HAIR_BASE: string = BASE
|
||||
const HAIR_TIMEOUT_MS: number = 120000
|
||||
const CLOTH_TIMEOUT_MS: number = 320000 // 服务端最长 ~300s,客户端必须更长(产品方案 §2.1.1)
|
||||
|
||||
@@ -78,26 +122,37 @@ export class TryonApi {
|
||||
return style.image_url.startsWith('http') ? style.image_url : `${BASE}${style.image_url}`
|
||||
}
|
||||
|
||||
/** 发型性别('male' | 'female' | 'unknown',unknown 在前端两个 Tab 都展示)。 */
|
||||
static hairstyleGender(id: string): string {
|
||||
if (MALE_HAIRSTYLE_IDS.has(id)) {
|
||||
return 'male'
|
||||
}
|
||||
if (FEMALE_HAIRSTYLE_IDS.has(id)) {
|
||||
return 'female'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 换发型。imageBuf 为已压缩 JPEG(ImageCodec.toJpegBuffer)。
|
||||
* 返回结果图的 Base64 Data URL。失败抛 ApiError(message 用服务端原文)。
|
||||
*/
|
||||
static async changeHair(imageBuf: ArrayBuffer, hairstyleId: string): Promise<string> {
|
||||
const form: http.MultiFormData[] = [
|
||||
{ name: 'image', contentType: 'image/jpeg', remoteFileName: 'photo.jpg', data: imageBuf },
|
||||
{ name: 'hairstyle_id', contentType: 'text/plain', data: hairstyleId }
|
||||
const parts: HairFormPart[] = [
|
||||
{ name: 'image', filename: 'photo.jpg', contentType: 'image/jpeg', data: new Uint8Array(imageBuf) },
|
||||
{ name: 'hairstyle_id', contentType: 'text/plain', data: TryonApi.encodeText(hairstyleId) }
|
||||
]
|
||||
return TryonApi.postHairForm(`${BASE}/hair/api/change-hair`, form)
|
||||
return TryonApi.postHairForm(`${HAIR_BASE}/hair/api/change-hair`, parts)
|
||||
}
|
||||
|
||||
/** 换发色。rgb 为字符串化数组("[255, 0, 0]"——服务端要的怪格式,不是 hex)。 */
|
||||
static async changeHairColor(imageBuf: ArrayBuffer, rgb: string, ratio: number): Promise<string> {
|
||||
const form: http.MultiFormData[] = [
|
||||
{ name: 'image', contentType: 'image/jpeg', remoteFileName: 'photo.jpg', data: imageBuf },
|
||||
{ name: 'rgb', contentType: 'text/plain', data: rgb },
|
||||
{ name: 'ratio', contentType: 'text/plain', data: `${ratio}` }
|
||||
const parts: HairFormPart[] = [
|
||||
{ name: 'image', filename: 'photo.jpg', contentType: 'image/jpeg', data: new Uint8Array(imageBuf) },
|
||||
{ name: 'rgb', contentType: 'text/plain', data: TryonApi.encodeText(rgb) },
|
||||
{ name: 'ratio', contentType: 'text/plain', data: TryonApi.encodeText(`${ratio}`) }
|
||||
]
|
||||
return TryonApi.postHairForm(`${BASE}/hair/api/change-hair-color`, form)
|
||||
return TryonApi.postHairForm(`${HAIR_BASE}/hair/api/change-hair-color`, parts)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +171,7 @@ export class TryonApi {
|
||||
'kuzi_img': kuziBase64,
|
||||
'change_desc': changeDesc
|
||||
}
|
||||
hilog.info(TRYON_DOMAIN, TRYON_TAG, 'cloth post %{public}s, json %{public}d bytes', BASE, JSON.stringify(body).length)
|
||||
const resp = await client.request(`${BASE}/change_cloth_base64`, {
|
||||
method: http.RequestMethod.POST,
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
@@ -124,12 +180,22 @@ export class TryonApi {
|
||||
connectTimeout: 15000,
|
||||
readTimeout: CLOTH_TIMEOUT_MS
|
||||
})
|
||||
const wire = JSON.parse(resp.result as string) as ChangeClothWire
|
||||
const respText: string = (resp.result ?? '') as string
|
||||
hilog.info(TRYON_DOMAIN, TRYON_TAG, 'cloth resp code=%{public}d body=%{public}s',
|
||||
resp.responseCode, respText.substring(0, 300))
|
||||
let wire: ChangeClothWire
|
||||
try {
|
||||
wire = JSON.parse(respText) as ChangeClothWire
|
||||
} catch (_p) {
|
||||
// 服务端 500 等会回纯文本 body(如 "Internal Server Error"):按服务繁忙口径提示
|
||||
throw new ApiError(50000, '服务繁忙,请稍后重试', '换装服务暂时不可用')
|
||||
}
|
||||
if (wire.ret === 0 && wire.data !== undefined && wire.data.length > 0) {
|
||||
return wire.data
|
||||
}
|
||||
throw new ApiError(50000, wire.msg || '换装生成失败', '请换张照片重试')
|
||||
} catch (e) {
|
||||
hilog.error(TRYON_DOMAIN, TRYON_TAG, 'cloth request failed: %{public}s', String(e))
|
||||
throw TryonApi.wrap(e, '换装请求失败')
|
||||
} finally {
|
||||
if (TryonApi.clothClient === client) {
|
||||
@@ -157,28 +223,76 @@ export class TryonApi {
|
||||
}
|
||||
}
|
||||
|
||||
private static async postHairForm(url: string, form: http.MultiFormData[]): Promise<string> {
|
||||
private static async postHairForm(url: string, parts: HairFormPart[]): Promise<string> {
|
||||
const client = http.createHttp()
|
||||
try {
|
||||
const body: ArrayBuffer = TryonApi.buildMultipart(parts)
|
||||
hilog.info(TRYON_DOMAIN, TRYON_TAG, 'hair post %{public}s, multipart %{public}d bytes', url, body.byteLength)
|
||||
const resp = await client.request(url, {
|
||||
method: http.RequestMethod.POST,
|
||||
multiFormDataList: form,
|
||||
header: { 'Content-Type': `multipart/form-data; boundary=${HAIR_BOUNDARY}` },
|
||||
extraData: body,
|
||||
expectDataType: http.HttpDataType.STRING,
|
||||
connectTimeout: 10000,
|
||||
readTimeout: HAIR_TIMEOUT_MS
|
||||
})
|
||||
const wire = JSON.parse(resp.result as string) as HairResultWire
|
||||
if (wire.success && wire.data !== undefined && wire.data.result_image.length > 0) {
|
||||
const bodyText: string = (resp.result ?? '') as string
|
||||
hilog.info(TRYON_DOMAIN, TRYON_TAG, 'hair resp code=%{public}d body=%{public}s',
|
||||
resp.responseCode, bodyText.substring(0, 300))
|
||||
// 线上返回键序不保证(实测 data 在前 success 在后),只认 data 判成功
|
||||
const wire = JSON.parse(bodyText) as HairResultWire
|
||||
if (wire.data !== undefined && wire.data.result_image.length > 0) {
|
||||
return wire.data.result_image
|
||||
}
|
||||
throw new ApiError(50000, wire.message ?? '生成失败', '请换张照片重试')
|
||||
} catch (e) {
|
||||
hilog.error(TRYON_DOMAIN, TRYON_TAG, 'hair request failed: %{public}s', String(e))
|
||||
throw TryonApi.wrap(e, '请求失败,请检查网络')
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
private static encodeText(s: string): Uint8Array {
|
||||
return new util.TextEncoder().encodeInto(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* 手工拼 multipart 报文。真机实测 http.multiFormDataList 发出的是
|
||||
* 「无 Content-Type + 0 字节 body」(服务端回 400 请上传图片),
|
||||
* 故按 RFC 7578 自行构造:extraData 发字节 + 显式 boundary 头。
|
||||
*/
|
||||
private static buildMultipart(parts: HairFormPart[]): ArrayBuffer {
|
||||
const enc = new util.TextEncoder()
|
||||
const dash: string = `--${HAIR_BOUNDARY}`
|
||||
const chunks: Uint8Array[] = []
|
||||
for (const p of parts) {
|
||||
let head: string = `${dash}\r\nContent-Disposition: form-data; name="${p.name}"`
|
||||
if (p.filename !== undefined) {
|
||||
head += `; filename="${p.filename}"`
|
||||
}
|
||||
if (p.contentType !== undefined) {
|
||||
head += `\r\nContent-Type: ${p.contentType}`
|
||||
}
|
||||
head += '\r\n\r\n'
|
||||
chunks.push(enc.encodeInto(head))
|
||||
chunks.push(p.data)
|
||||
chunks.push(enc.encodeInto('\r\n'))
|
||||
}
|
||||
chunks.push(enc.encodeInto(`${dash}--\r\n`))
|
||||
let total: number = 0
|
||||
for (const c of chunks) {
|
||||
total += c.byteLength
|
||||
}
|
||||
const out = new Uint8Array(total)
|
||||
let off: number = 0
|
||||
for (const c of chunks) {
|
||||
out.set(c, off)
|
||||
off += c.byteLength
|
||||
}
|
||||
return out.buffer
|
||||
}
|
||||
|
||||
/** 与 ApiClient 同一纪律:未知异常包装成 ApiError。 */
|
||||
private static wrap(e: Object, fallback: string): ApiError {
|
||||
if (e instanceof ApiError) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* UploadApi:诊断照片直传本机后端(无 COS 四件套的本地磁盘降级路线)。
|
||||
*
|
||||
* 后端 POST /api/v1/uploads/direct 收原始字节(后端仓库未引入
|
||||
* python-multipart,不收 multipart 表单;这也正好绕开真机
|
||||
* http.multiFormDataList 的 0 字节 bug):
|
||||
* - body 即图片字节(ImageCodec.toJpegBuffer 压缩后的 JPEG)
|
||||
* - Content-Type 头申报类型;scene 走 query(默认 report_photo)
|
||||
* - 响应走统一信封,data.object_key 回传给 POST /api/v1/reports
|
||||
*
|
||||
* 错误一律抛 ApiError(与 ApiClient 约定一致,UI 层只 catch ApiError)。
|
||||
* 鉴权头从 AuthStore 取;401 自动刷新不在此处做(与 TryonApi 同口径,
|
||||
* 令牌过期由下一次 ApiClient 请求兜底刷新)。
|
||||
*/
|
||||
|
||||
import http from '@ohos.net.http'
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit'
|
||||
import { ApiError } from './ApiTypes'
|
||||
import { AuthStore } from './AuthStore'
|
||||
|
||||
const UPLOAD_DOMAIN: number = 0x0201
|
||||
const UPLOAD_TAG: string = 'MEIDA_UPLOAD'
|
||||
const UPLOAD_READ_TIMEOUT_MS: number = 60000 // 压缩后单张 ~300KB,60s 覆盖弱网
|
||||
|
||||
/** POST /uploads/direct 响应的 data 段(字段名对齐后端 JSON)。 */
|
||||
export interface DirectUploadData {
|
||||
object_key?: string
|
||||
size_bytes?: number
|
||||
content_type?: string
|
||||
}
|
||||
|
||||
/** 直传响应信封(同 ApiEnvelope 形状,data 可选以防御后端缺省)。 */
|
||||
interface DirectUploadWire {
|
||||
code: number
|
||||
message: string
|
||||
data?: DirectUploadData
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export class UploadApi {
|
||||
/**
|
||||
* 上传诊断照片(已压缩 JPEG 字节),返回真实 object_key。
|
||||
* 失败抛 ApiError(message/hint 用服务端信封原文,可直接展示重试)。
|
||||
*/
|
||||
static async uploadReportPhoto(imageBuf: ArrayBuffer): Promise<string> {
|
||||
const token = AuthStore.getAccessToken()
|
||||
if (token === undefined) {
|
||||
// 与 ApiClient 同一纪律:未登录直接短路,省一次必然 401 的往返
|
||||
throw new ApiError(40100, '请先登录', '登录后即可继续')
|
||||
}
|
||||
const base = await AuthStore.getBaseUrl()
|
||||
const url = `${base}/api/v1/uploads/direct?scene=report_photo`
|
||||
const client = http.createHttp()
|
||||
try {
|
||||
const header: Record<string, string> = {
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
hilog.info(UPLOAD_DOMAIN, UPLOAD_TAG, 'direct post %{public}s, %{public}d bytes', url, imageBuf.byteLength)
|
||||
const resp = await client.request(url, {
|
||||
method: http.RequestMethod.POST,
|
||||
header: header,
|
||||
extraData: imageBuf,
|
||||
expectDataType: http.HttpDataType.STRING,
|
||||
connectTimeout: 10000,
|
||||
readTimeout: UPLOAD_READ_TIMEOUT_MS
|
||||
})
|
||||
const bodyText: string = (resp.result ?? '') as string
|
||||
hilog.info(UPLOAD_DOMAIN, UPLOAD_TAG, 'direct resp code=%{public}d body=%{public}s',
|
||||
resp.responseCode, bodyText.substring(0, 300))
|
||||
let wire: DirectUploadWire
|
||||
try {
|
||||
wire = JSON.parse(bodyText) as DirectUploadWire
|
||||
} catch (_p) {
|
||||
// 网关/代理层错误:body 不是信封 JSON,给统一话术(对齐 ApiClient)
|
||||
throw new ApiError(50000, '服务开小差了', '请稍后重试', resp.responseCode)
|
||||
}
|
||||
const key = wire.data?.object_key ?? ''
|
||||
if (wire.code === 0 && key !== '') {
|
||||
return key
|
||||
}
|
||||
if (wire.code !== 0) {
|
||||
throw new ApiError(wire.code, wire.message, wire.hint, resp.responseCode)
|
||||
}
|
||||
// code=0 但没有 object_key:契约破损,按服务端异常处理
|
||||
throw new ApiError(50000, '照片上传结果异常', '请稍后重试', resp.responseCode)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
throw e
|
||||
}
|
||||
hilog.error(UPLOAD_DOMAIN, UPLOAD_TAG, 'direct request failed: %{public}s', String(e))
|
||||
throw new ApiError(50000, '照片上传失败,请检查网络', '请稍后重试')
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* WardrobeStore —— 「我的衣橱」本机存取(需求 v2 §3.3)。
|
||||
*
|
||||
* - 元数据(品类标签、名称、添加时间)写 preferences(meida_wardrobe),
|
||||
* 图片文件压缩转存 filesDir/wardrobe/<id>.jpg,App 卸载即清,不上传服务端;
|
||||
* - 相册/拍照返回的临时 URI 重启后可能失效,入库时必须用 ImageCodec 压出
|
||||
* 自有副本(顺带统一为长边 ≤1600 的 JPEG,预览与生成共用同一来源);
|
||||
* - 条目 {id, uri, category, name, createdAt};category ∈ 五品类,
|
||||
* 下装(kuzi 档)与上衣档(上衣/连衣裙/外套/套装)的降级映射见需求 §3.1。
|
||||
* - 先例:AuthStore/FaceConsentStore 的 preferences + JSON 串口径。
|
||||
*/
|
||||
|
||||
import preferences from '@ohos.data.preferences'
|
||||
import fs from '@ohos.file.fs'
|
||||
import { ContextHolder } from './ContextHolder'
|
||||
import { ImageCodec } from './ImageCodec'
|
||||
import { ApiError } from './ApiTypes'
|
||||
|
||||
/** 衣橱条目(uri 为本机沙盒绝对路径,展示/编码时经 fileUri 转 file:// URI)。 */
|
||||
export interface WardrobeItem {
|
||||
id: string
|
||||
uri: string // 本机路径:filesDir/wardrobe/<id>.jpg
|
||||
category: string // 上衣 / 下装 / 连衣裙 / 外套 / 套装
|
||||
name: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/** 五个品类标签(UI 规范 §8 文案,勿改)。 */
|
||||
export const CLOTH_CATEGORIES: string[] = ['上衣', '下装', '连衣裙', '外套', '套装']
|
||||
|
||||
const PREF_NAME: string = 'meida_wardrobe'
|
||||
const KEY_ITEMS: string = 'items'
|
||||
|
||||
export class WardrobeStore {
|
||||
private static items: WardrobeItem[] = []
|
||||
private static prefs: preferences.Preferences | undefined
|
||||
|
||||
/** hydrate preferences → 内存;幂等。preferences 不可用时退化为空衣橱(不阻断页面)。 */
|
||||
static async init(): Promise<void> {
|
||||
if (WardrobeStore.prefs !== undefined) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
WardrobeStore.prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME })
|
||||
const raw = await WardrobeStore.prefs.get(KEY_ITEMS, '') as string
|
||||
if (raw !== '') {
|
||||
const parsed = JSON.parse(raw) as WardrobeItem[]
|
||||
WardrobeStore.items = Array.isArray(parsed)
|
||||
? parsed.filter((it: WardrobeItem): boolean => typeof it.id === 'string' && it.id.length > 0
|
||||
&& typeof it.uri === 'string' && it.uri.length > 0)
|
||||
: []
|
||||
}
|
||||
} catch (_e) {
|
||||
WardrobeStore.items = []
|
||||
}
|
||||
WardrobeStore.sortByNewest()
|
||||
}
|
||||
|
||||
/** 全部条目(新添加在前)。 */
|
||||
static list(): WardrobeItem[] {
|
||||
return WardrobeStore.items
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传入库:选/拍到的图先压缩为 JPEG 写入 filesDir/wardrobe,再写元数据。
|
||||
* 任一步失败抛 ApiError(不残留半截条目)。
|
||||
*/
|
||||
static async add(pickedUri: string, category: string): Promise<WardrobeItem> {
|
||||
const id: string = `w${Date.now()}${Math.floor(Math.random() * 1000)}`
|
||||
const dir: string = WardrobeStore.dir()
|
||||
const path: string = `${dir}/${id}.jpg`
|
||||
try {
|
||||
if (!fs.accessSync(dir)) {
|
||||
fs.mkdirSync(dir, true)
|
||||
}
|
||||
const buf = await ImageCodec.toJpegBuffer(pickedUri)
|
||||
const file = fs.openSync(path, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC)
|
||||
try {
|
||||
fs.writeSync(file.fd, buf)
|
||||
} finally {
|
||||
fs.closeSync(file)
|
||||
}
|
||||
} catch (_e) {
|
||||
throw new ApiError(50000, '衣服图片保存失败', '请重试')
|
||||
}
|
||||
const sameCat: number = WardrobeStore.items
|
||||
.filter((it: WardrobeItem): boolean => it.category === category).length
|
||||
const item: WardrobeItem = {
|
||||
id: id,
|
||||
uri: path,
|
||||
category: category,
|
||||
name: `${category} ${sameCat + 1}`,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
WardrobeStore.items.push(item)
|
||||
WardrobeStore.sortByNewest()
|
||||
await WardrobeStore.persist()
|
||||
return item
|
||||
}
|
||||
|
||||
/** 移除条目并尽力删文件;文件残留不阻断(列表仍刷新,元数据已同步)。 */
|
||||
static async remove(id: string): Promise<void> {
|
||||
const idx: number = WardrobeStore.items.findIndex((it: WardrobeItem): boolean => it.id === id)
|
||||
if (idx < 0) {
|
||||
return
|
||||
}
|
||||
const path: string = WardrobeStore.items[idx].uri
|
||||
WardrobeStore.items.splice(idx, 1)
|
||||
await WardrobeStore.persist()
|
||||
try {
|
||||
if (fs.accessSync(path)) {
|
||||
fs.unlinkSync(path)
|
||||
}
|
||||
} catch (_e) {
|
||||
// 文件可能已被系统清理,忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否下装档(kuzi_img):只有「下装」一类,须伴随上衣才能生成(需求 §3.1)。 */
|
||||
static isBottom(category: string): boolean {
|
||||
return category === '下装'
|
||||
}
|
||||
|
||||
private static dir(): string {
|
||||
return `${ContextHolder.get().filesDir}/wardrobe`
|
||||
}
|
||||
|
||||
private static sortByNewest(): void {
|
||||
WardrobeStore.items.sort((a: WardrobeItem, b: WardrobeItem): number => b.createdAt - a.createdAt)
|
||||
}
|
||||
|
||||
/** 尽力写盘:失败不阻断 UI(最坏情况下次启动重读旧值)。 */
|
||||
private static async persist(): Promise<void> {
|
||||
try {
|
||||
if (WardrobeStore.prefs === undefined) {
|
||||
WardrobeStore.prefs = await preferences.getPreferences(ContextHolder.get(), { name: PREF_NAME })
|
||||
}
|
||||
await WardrobeStore.prefs.put(KEY_ITEMS, JSON.stringify(WardrobeStore.items))
|
||||
await WardrobeStore.prefs.flush()
|
||||
} catch (_e) {
|
||||
// 内存态已生效,持久化失败下次启动重读旧值
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { window } from '@kit.ArkUI';
|
||||
import { ContextHolder } from '../core/ContextHolder';
|
||||
import { AuthStore } from '../core/AuthStore';
|
||||
import { FaceConsentStore } from '../components/f2/FacePhotoConsent';
|
||||
import { Tracker } from '../core/Tracker';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
|
||||
@@ -14,6 +15,8 @@ export default class EntryAbility extends UIAbility {
|
||||
AuthStore.init()
|
||||
// 人脸同意记录持久化(N4):hydrate preferences → 内存,两页门控零改动读内存
|
||||
FaceConsentStore.init()
|
||||
// 端侧埋点(P0-5):hydrate 持久化队列 + 启 30s 节流定时器(不 await,不阻断启动)
|
||||
Tracker.init()
|
||||
try {
|
||||
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
||||
} catch (err) {
|
||||
@@ -52,5 +55,7 @@ export default class EntryAbility extends UIAbility {
|
||||
onBackground(): void {
|
||||
// Ability has back to background
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
|
||||
// 退后台尽力 flush 一次埋点队列(不 await)
|
||||
Tracker.flushOnBackground();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { router } from '@kit.ArkUI'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError, isAuthError } from '../core/ApiTypes'
|
||||
import { ApiError, isAuthError, isNetworkError } from '../core/ApiTypes'
|
||||
import { Tracker } from '../core/Tracker'
|
||||
import { COLOR_TEXT_SECONDARY } from '../core/Theme'
|
||||
import { VoiceInputEngine } from '../components/f4/VoiceInputEngine'
|
||||
import { TtsEngine } from '../components/f4/TtsEngine'
|
||||
import { formatIsoTime, randomHex } from '../components/f3/ReportTypes'
|
||||
import { TaskBonusPanel } from '../components/f1/TaskBonusPanel'
|
||||
import { NetworkBanner } from '../components/f2/NetworkBanner'
|
||||
|
||||
interface ChatUsage {
|
||||
tokens_in: number
|
||||
@@ -30,7 +33,7 @@ interface QuotaWireResp {
|
||||
quota_key: string
|
||||
used: number
|
||||
limit_value: number
|
||||
bonus_granted: boolean
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
reset_at: string
|
||||
}
|
||||
@@ -139,6 +142,8 @@ struct ChatPage {
|
||||
@State voiceHint: string = ''
|
||||
@State speakingMsgIdx: number = -1
|
||||
@State authExpired: boolean = false // 会话/令牌类失败:给「重新登录」出路(#7)
|
||||
@State showTaskPanel: boolean = false // 额度耗尽 →「做任务换额度」半屏面板(P0-4)
|
||||
@State quotaNetError: boolean = false // 额度预拉遇网络错误(P0-6):列表顶部 NetworkBanner
|
||||
private fingerDown: boolean = false
|
||||
private holdStartY: number = 0
|
||||
private voiceEngine: VoiceInputEngine = new VoiceInputEngine()
|
||||
@@ -230,13 +235,18 @@ struct ChatPage {
|
||||
this.send()
|
||||
}
|
||||
|
||||
/** 预拉今日对话额度;失败保持 -1(横幅不展示,是否放行交给服务端校验)。 */
|
||||
/** 预拉今日对话额度;网络错误上横幅给重试(P0-6),其他失败保持 -1 由服务端校验放行。 */
|
||||
private async loadQuotaRemaining(): Promise<void> {
|
||||
this.quotaNetError = false
|
||||
try {
|
||||
const d = await ApiClient.get<QuotaWireResp>('/api/v1/quota?quota_key=agent_chat')
|
||||
this.quotaRemaining = d.remaining
|
||||
this.quotaResetAt = d.reset_at ?? ''
|
||||
} catch (_e) {
|
||||
} catch (e) {
|
||||
if (isNetworkError(e)) {
|
||||
this.quotaNetError = true
|
||||
return
|
||||
}
|
||||
this.quotaRemaining = -1
|
||||
this.quotaResetAt = ''
|
||||
}
|
||||
@@ -272,6 +282,7 @@ struct ChatPage {
|
||||
* 成功后即作废(下一轮重新生成)。
|
||||
*/
|
||||
private async startTurn(text: string, retryKey: string): Promise<void> {
|
||||
Tracker.track('chat.send', { 'has_quick': QUICK_QUESTIONS.includes(text) })
|
||||
const requestKey: string = retryKey !== '' ? retryKey : randomHex(16)
|
||||
this.inputText = ''
|
||||
this.sending = true
|
||||
@@ -298,10 +309,12 @@ struct ChatPage {
|
||||
this.speakText(data.reply, aiIdx, false)
|
||||
}
|
||||
} catch (e) {
|
||||
Tracker.track('chat.send_failed', { 'code': e instanceof ApiError ? e.code : 50000 })
|
||||
if (e instanceof ApiError) {
|
||||
this.replaceThinking(e.message, e.hint ?? '')
|
||||
if (e.code === QUOTA_EXHAUSTED_CODE) {
|
||||
// 额度耗尽(N7):同步刷新额度态——横幅换「已用完+恢复时间」、发送按钮置灰
|
||||
Tracker.track('quota.exhausted_view', { 'quota_key': 'agent_chat' })
|
||||
this.loadQuotaRemaining()
|
||||
}
|
||||
}
|
||||
@@ -436,6 +449,18 @@ struct ChatPage {
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
/** 「做任务换额度」半屏面板(P0-4):到账的是 agent_chat 额度时立即刷新剩余数。 */
|
||||
@Builder
|
||||
TaskBonusSheet() {
|
||||
TaskBonusPanel({
|
||||
onClaimed: (quotaKey: string): void => {
|
||||
if (quotaKey === 'agent_chat') {
|
||||
this.loadQuotaRemaining()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
Column() {
|
||||
@@ -480,16 +505,29 @@ struct ChatPage {
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
} else if (this.quotaRemaining === 0) {
|
||||
// 剩余 0:明确告知恢复时间(口径同报告域确认框),发送入口同步置灰
|
||||
Text(this.quotaResetAt !== ''
|
||||
? `今日对话额度已用完,${formatIsoTime(this.quotaResetAt)} 后恢复`
|
||||
: '今日对话额度已用完,请明天再试')
|
||||
.fontSize(12)
|
||||
.fontColor('#C0392B')
|
||||
.backgroundColor('#FDECEC')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
// 剩余 0(P0-4 + P0-6):规格文案 + 恢复时间(reset_at 经 formatIsoTime 转本地时区,
|
||||
// N5 先例见 ProfileContent)+「做任务换额度」出路,发送入口同步置灰
|
||||
Row({ space: 8 }) {
|
||||
Text(this.quotaResetAt !== ''
|
||||
? `今日对话额度已用完,${formatIsoTime(this.quotaResetAt)} 后恢复;也可做任务多换几次`
|
||||
: '今日对话额度已用完,明天再来,或做任务多换几次')
|
||||
.fontSize(12)
|
||||
.fontColor('#C0392B')
|
||||
.layoutWeight(1)
|
||||
Text('做任务换额度')
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_TEXT_SECONDARY)
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.onClick(() => {
|
||||
this.showTaskPanel = true
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
|
||||
.backgroundColor('#FDECEC')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
// 会话过期横幅(#7):此时重试必败,唯一出路是重新登录
|
||||
@@ -515,8 +553,18 @@ struct ChatPage {
|
||||
.backgroundColor('#FDECEC')
|
||||
}
|
||||
|
||||
List({ scroller: this.scroller }) {
|
||||
ForEach(this.messages, (msg: ChatMsg, idx: number) => {
|
||||
// P0-6:额度预拉遇网络错误——消息列表顶部横幅,重试重新拉额度
|
||||
// (startTurn 的 50000 失败维持现有气泡重试,不重复上横幅)
|
||||
if (this.quotaNetError) {
|
||||
NetworkBanner({
|
||||
onRetry: (): void => {
|
||||
Tracker.track('network.banner_retry', { 'page': 'chat' })
|
||||
this.loadQuotaRemaining()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
List({ scroller: this.scroller }) { ForEach(this.messages, (msg: ChatMsg, idx: number) => {
|
||||
ListItem() {
|
||||
MessageBubble({
|
||||
role: msg.role,
|
||||
@@ -664,5 +712,9 @@ struct ChatPage {
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.bindSheet($$this.showTaskPanel, this.TaskBonusSheet(), {
|
||||
height: '60%',
|
||||
showClose: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* DiagnosisFormPage —— 美学诊断信息填写页(F3,需求 v2 §4.4)。
|
||||
*
|
||||
* 白底表单页:
|
||||
* - 「基本信息」白卡分组表单(FormRow):
|
||||
* 性别(Segment 男/女,必填)、出生日期(点击弹 DatePicker 1930~今天,
|
||||
* 行内显示 YYYY-MM-DD(N岁))、身高(滚轮 100~250 默认 165)、
|
||||
* 体重(滚轮 25~300 默认 55)、职业(常用 chips 单选,选填)。
|
||||
* 滚轮/日期弹层写法沿用 ReportCreatePage 先例(getUIContext().showTextPickerDialog /
|
||||
* DatePicker dialog),已有值从当前值滚起(clamp 写法一致)。
|
||||
* - 校验:性别/出生日期/身高/体重必填,职业可空;缺失行下红字 12 COLOR_ERROR
|
||||
* + 汇总条「请完成:…」(动态拼接)+ 滚动到首个缺失项(沿用 ReportCreatePage 口径)。
|
||||
* - 「下一步」吸底主按钮:全部合法 → router.pushUrl pages/ReportCreatePage,
|
||||
* 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'
|
||||
import {
|
||||
COLOR_PRIMARY,
|
||||
COLOR_PRIMARY_SOFT,
|
||||
COLOR_TEXT_PRIMARY,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
COLOR_DIVIDER,
|
||||
COLOR_ERROR,
|
||||
COLOR_IMAGE_BG
|
||||
} from '../core/Theme'
|
||||
import { Segment } from '../components/f2/Segment'
|
||||
import { FormRow } from '../components/f3/FormRow'
|
||||
import { formatDate } from '../components/f3/ReportTypes'
|
||||
|
||||
// 性别分段(UI 规范 §8:男/女)
|
||||
const GENDER_OPTIONS: string[] = ['男', '女']
|
||||
// 常用职业 chips(需求 §4.4,单选、选填)
|
||||
const OCCUPATION_OPTIONS: string[] = ['学生', '上班族', '教师', '医护', '公务员', '自由职业', '其他']
|
||||
|
||||
// 身高/体重滚轮口径与 ReportCreatePage 完全一致(100~250 / 25~300)
|
||||
const HEIGHT_MIN: number = 100
|
||||
const HEIGHT_MAX: number = 250
|
||||
const HEIGHT_DEFAULT: number = 165
|
||||
const WEIGHT_MIN: number = 25
|
||||
const WEIGHT_MAX: number = 300
|
||||
const WEIGHT_DEFAULT: number = 55
|
||||
|
||||
// 出生日期范围(需求 §4.4:1930~今天)
|
||||
const BIRTHDAY_MIN: Date = new Date(1930, 0, 1)
|
||||
// 未选出生日期时弹层的默认落点
|
||||
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[] = []
|
||||
for (let v = min; v <= max; v++) {
|
||||
arr.push(`${v}`)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
const HEIGHT_OPTIONS: string[] = buildRangeOptions(HEIGHT_MIN, HEIGHT_MAX)
|
||||
const WEIGHT_OPTIONS: string[] = buildRangeOptions(WEIGHT_MIN, WEIGHT_MAX)
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct DiagnosisFormPage {
|
||||
@State gender: string = '' // '' | '男' | '女',默认不选
|
||||
@State birthday: string = '' // YYYY-MM-DD;空串 = 未选
|
||||
@State heightInput: string = ''
|
||||
@State weightInput: string = ''
|
||||
@State occupation: string = ''
|
||||
@State genderErr: string = ''
|
||||
@State birthdayErr: string = ''
|
||||
@State heightErr: string = ''
|
||||
@State weightErr: string = ''
|
||||
@State formSummary: string = ''
|
||||
@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 === '') {
|
||||
return 0
|
||||
}
|
||||
const d = new Date(this.birthday)
|
||||
if (isNaN(d.getTime())) {
|
||||
return 0
|
||||
}
|
||||
const now = new Date()
|
||||
let age = now.getFullYear() - d.getFullYear()
|
||||
const m = now.getMonth() - d.getMonth()
|
||||
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) {
|
||||
age--
|
||||
}
|
||||
return age
|
||||
}
|
||||
|
||||
/** TextPickerResult.index 是联合类型(多列选择器为数组),单列取数字下标。 */
|
||||
private pickIndex(v: TextPickerResult): number {
|
||||
return typeof v.index === 'number' ? v.index : -1
|
||||
}
|
||||
|
||||
private openBirthdayPicker(): void {
|
||||
const parsed = this.birthday !== '' ? new Date(this.birthday) : BIRTHDAY_FALLBACK_SELECTED
|
||||
const today = new Date()
|
||||
// 已有值从当前值滚起;解析失败或越界时落在默认值/今天
|
||||
const selected = isNaN(parsed.getTime()) || parsed.getTime() > today.getTime() ? today : parsed
|
||||
this.getUIContext().showDatePickerDialog({
|
||||
start: BIRTHDAY_MIN,
|
||||
end: today,
|
||||
selected: selected,
|
||||
canLoop: false,
|
||||
onDateAccept: (value: Date): void => {
|
||||
this.birthday = formatDate(value)
|
||||
this.birthdayErr = ''
|
||||
this.saveDraft()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 已有值从当前值滚起,未选过从常见默认值(165cm)滚起(沿用 ReportCreatePage)。 */
|
||||
private openHeightPicker(): void {
|
||||
const cur = this.heightInput !== '' ? Number(this.heightInput) : HEIGHT_DEFAULT
|
||||
const clamped = Math.min(Math.max(cur - HEIGHT_MIN, 0), HEIGHT_OPTIONS.length - 1)
|
||||
this.getUIContext().showTextPickerDialog({
|
||||
range: HEIGHT_OPTIONS,
|
||||
selected: clamped,
|
||||
canLoop: false,
|
||||
onAccept: (value: TextPickerResult) => {
|
||||
const idx = this.pickIndex(value)
|
||||
if (idx >= 0 && idx < HEIGHT_OPTIONS.length) {
|
||||
this.heightInput = HEIGHT_OPTIONS[idx]
|
||||
this.heightErr = ''
|
||||
this.saveDraft()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private openWeightPicker(): void {
|
||||
const cur = this.weightInput !== '' ? Number(this.weightInput) : WEIGHT_DEFAULT
|
||||
const clamped = Math.min(Math.max(cur - WEIGHT_MIN, 0), WEIGHT_OPTIONS.length - 1)
|
||||
this.getUIContext().showTextPickerDialog({
|
||||
range: WEIGHT_OPTIONS,
|
||||
selected: clamped,
|
||||
canLoop: false,
|
||||
onAccept: (value: TextPickerResult) => {
|
||||
const idx = this.pickIndex(value)
|
||||
if (idx >= 0 && idx < WEIGHT_OPTIONS.length) {
|
||||
this.weightInput = WEIGHT_OPTIONS[idx]
|
||||
this.weightErr = ''
|
||||
this.saveDraft()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private validateForm(): boolean {
|
||||
// 选择器产出必然在合法区间,未选才是唯一错误态
|
||||
this.genderErr = this.gender === '' ? '请选择性别' : ''
|
||||
this.birthdayErr = this.birthday === '' ? '请选择出生日期' : ''
|
||||
this.heightErr = this.heightInput === '' ? '请选择身高' : ''
|
||||
this.weightErr = this.weightInput === '' ? '请选择体重' : ''
|
||||
return this.genderErr === '' && this.birthdayErr === '' && this.heightErr === '' && this.weightErr === ''
|
||||
}
|
||||
|
||||
private missingLabels(): string[] {
|
||||
const labels: string[] = []
|
||||
if (this.genderErr !== '') {
|
||||
labels.push('性别')
|
||||
}
|
||||
if (this.birthdayErr !== '') {
|
||||
labels.push('出生日期')
|
||||
}
|
||||
if (this.heightErr !== '') {
|
||||
labels.push('身高')
|
||||
}
|
||||
if (this.weightErr !== '') {
|
||||
labels.push('体重')
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
private scrollToFirstError(): void {
|
||||
let firstId = ''
|
||||
if (this.genderErr !== '') {
|
||||
firstId = 'field_gender'
|
||||
} else if (this.birthdayErr !== '') {
|
||||
firstId = 'field_birthday'
|
||||
} else if (this.heightErr !== '') {
|
||||
firstId = 'field_height'
|
||||
} else if (this.weightErr !== '') {
|
||||
firstId = 'field_weight'
|
||||
}
|
||||
if (firstId === '') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// windowOffset 随滚动同步移动,两处相减即为内容坐标差,
|
||||
// 与当前滚动位置无关(沿用 ReportCreatePage 口径)
|
||||
const root = componentUtils.getRectangleById('form_root')
|
||||
const target = componentUtils.getRectangleById(firstId)
|
||||
const dy = target.windowOffset.y - root.windowOffset.y - 8
|
||||
this.scroller.scrollTo({ xOffset: 0, yOffset: Math.max(0, dy), animation: { duration: 250, curve: Curve.EaseOut } })
|
||||
} catch (e) {
|
||||
console.error(`scrollTo ${firstId} failed: ${String(e).substring(0, 80)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private onNextTap(): void {
|
||||
this.formSummary = ''
|
||||
this.navError = ''
|
||||
if (!this.validateForm()) {
|
||||
// 字段级红字可能滚在视野外:汇总条点名缺哪几项,并自动滚到第一个缺失字段
|
||||
const labels: string[] = this.missingLabels()
|
||||
this.formSummary = labels.length > 0 ? `请完成:${labels.join('、')}` : '请检查标红的字段'
|
||||
this.scrollToFirstError()
|
||||
return
|
||||
}
|
||||
// 职业仅本地留存,不进提交体(需求 §3.6/验收 4);整份草稿一并写回
|
||||
this.saveDraft()
|
||||
// 四个值全部为 string,接收端 ReportCreatePage 按 string 读取
|
||||
const params: Record<string, Object> = {
|
||||
'gender': this.gender,
|
||||
'birthday': this.birthday,
|
||||
'height_cm': this.heightInput,
|
||||
'weight_kg': this.weightInput
|
||||
}
|
||||
router.pushUrl({ url: 'pages/ReportCreatePage', params: params })
|
||||
.catch((e: Object): void => {
|
||||
this.navError = `跳转失败,请重试:${String(e).substring(0, 80)}`
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
TopBar() {
|
||||
Row({ space: 8 }) {
|
||||
Text('‹ 返回')
|
||||
.fontSize(15)
|
||||
.fontColor(COLOR_TEXT_SECONDARY)
|
||||
.padding({ left: 4, right: 4, top: 2, bottom: 2 })
|
||||
.onClick(() => {
|
||||
router.back()
|
||||
})
|
||||
Text('形象信息')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
}
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.padding({ left: 12, right: 16 })
|
||||
.backgroundColor(Color.White)
|
||||
}
|
||||
|
||||
@Builder
|
||||
GenderCell() {
|
||||
Segment({
|
||||
items: GENDER_OPTIONS,
|
||||
selected: this.gender === GENDER_OPTIONS[0] ? 0 : (this.gender === GENDER_OPTIONS[1] ? 1 : -1),
|
||||
onSelect: (idx: number): void => {
|
||||
if (idx >= 0 && idx < GENDER_OPTIONS.length) {
|
||||
this.gender = GENDER_OPTIONS[idx]
|
||||
this.genderErr = ''
|
||||
this.saveDraft()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
OccupationCell() {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(OCCUPATION_OPTIONS, (o: string) => {
|
||||
Text(o)
|
||||
.fontSize(13)
|
||||
.fontColor(this.occupation === o ? Color.White : COLOR_TEXT_PRIMARY)
|
||||
.backgroundColor(this.occupation === o ? COLOR_PRIMARY : COLOR_IMAGE_BG)
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.borderRadius(13)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
.onClick(() => {
|
||||
this.occupation = this.occupation === o ? '' : o
|
||||
this.saveDraft()
|
||||
})
|
||||
}, (o: string) => o)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
this.TopBar()
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
Text('基本信息')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
.width('100%')
|
||||
.margin({ top: 4 })
|
||||
|
||||
// 分组卡:白底圆角 12,行间分隔线 0.5vp 由 FormRow 内置
|
||||
Column({ space: 0 }) {
|
||||
Column() {
|
||||
FormRow({
|
||||
label: '性别',
|
||||
errorText: this.genderErr,
|
||||
showArrow: false,
|
||||
useCustomContent: true,
|
||||
rowContent: (): void => {
|
||||
this.GenderCell()
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.id('field_gender')
|
||||
|
||||
Column() {
|
||||
FormRow({
|
||||
label: '出生日期',
|
||||
value: this.birthday === '' ? '' : `${this.birthday}(${this.ageOf()}岁)`,
|
||||
errorText: this.birthdayErr,
|
||||
onRowClick: (): void => {
|
||||
this.openBirthdayPicker()
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.id('field_birthday')
|
||||
|
||||
Column() {
|
||||
FormRow({
|
||||
label: '身高',
|
||||
value: this.heightInput === '' ? '' : `${this.heightInput} cm`,
|
||||
errorText: this.heightErr,
|
||||
onRowClick: (): void => {
|
||||
this.openHeightPicker()
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.id('field_height')
|
||||
|
||||
Column() {
|
||||
FormRow({
|
||||
label: '体重',
|
||||
value: this.weightInput === '' ? '' : `${this.weightInput} kg`,
|
||||
errorText: this.weightErr,
|
||||
onRowClick: (): void => {
|
||||
this.openWeightPicker()
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.id('field_weight')
|
||||
|
||||
FormRow({
|
||||
label: '职业',
|
||||
showDivider: false,
|
||||
useCustomContent: true,
|
||||
customAlignEnd: false,
|
||||
rowContent: (): void => {
|
||||
this.OccupationCell()
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(12)
|
||||
.backgroundColor(Color.White)
|
||||
|
||||
Text('信息仅用于生成你的个人报告,不会对外展示或共享。')
|
||||
.fontSize(11)
|
||||
.fontColor(COLOR_TEXT_SECONDARY)
|
||||
.width('100%')
|
||||
.padding({ left: 4, right: 4 })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding({ left: 16, right: 16, top: 16, bottom: 24 })
|
||||
.id('form_root')
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
|
||||
// 吸底区:汇总条 + 兜底错误 + 主按钮
|
||||
Column({ space: 8 }) {
|
||||
if (this.formSummary !== '') {
|
||||
Text(this.formSummary)
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.width('100%')
|
||||
.padding(10)
|
||||
.backgroundColor(COLOR_PRIMARY_SOFT)
|
||||
.borderRadius(8)
|
||||
.textAlign(TextAlign.Start)
|
||||
.onClick(() => {
|
||||
this.scrollToFirstError()
|
||||
})
|
||||
}
|
||||
if (this.navError !== '') {
|
||||
Text(this.navError)
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
}
|
||||
Button('下一步')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(Color.White)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.borderRadius(24)
|
||||
.onClick(() => {
|
||||
this.onNextTap()
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
|
||||
.backgroundColor(Color.White)
|
||||
.border({ width: { top: 0.5 }, color: COLOR_DIVIDER })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(Color.White)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* DiagnosisLandingPage —— 美学诊断落地页(F3,需求 v2 §4.3)。
|
||||
*
|
||||
* 纯静态营销页:零网络请求、零本地存储,弱网/失败态不适用。
|
||||
* - 白底顶栏「美学诊断」(返回 + 标题);
|
||||
* - 紫渐变 hero(COLOR_DIAG_HERO_TOP→COLOR_DIAG_HERO_BOTTOM,全 App 仅此页用紫):
|
||||
* 大标「个人形象风格分析报告」白 26 Bold + 副标「5 分钟,看清你的穿衣风格」白85% 13;
|
||||
* - 「你的穿衣困扰」白卡 5 行(主色圆点 8vp + 文字 14,文案照 UI 规范 §8 逐字);
|
||||
* - 「如何生成你的报告」StepBar 四步(测一测/分析/生成/报告);
|
||||
* - 吸底 CTA「马上体验」(主色玫红,非紫)→ pages/DiagnosisFormPage,
|
||||
* CTA 上小字「我们会保障您的个人信息安全」。
|
||||
*/
|
||||
|
||||
import { router, display } from '@kit.ArkUI'
|
||||
import { Tracker } from '../core/Tracker'
|
||||
import {
|
||||
COLOR_PRIMARY,
|
||||
COLOR_TEXT_PRIMARY,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
COLOR_DIVIDER,
|
||||
COLOR_PAGE_BG,
|
||||
COLOR_DIAG_HERO_TOP,
|
||||
COLOR_DIAG_HERO_BOTTOM
|
||||
} from '../core/Theme'
|
||||
import { StepBar } from '../components/f3/StepBar'
|
||||
|
||||
/** 困扰清单 5 条(UI 规范 §8 文案,逐字照抄)。 */
|
||||
const PROBLEMS: string[] = [
|
||||
'不知道自己适合什么风格',
|
||||
'衣柜很多衣服却不会搭',
|
||||
'穿显胖显黑,不敢尝试',
|
||||
'想改变却没有方向',
|
||||
'每次出门搭配浪费时间'
|
||||
]
|
||||
|
||||
/** 四步流程(UI 规范 §8 文案,逐字照抄)。 */
|
||||
const STEPS: string[] = ['测一测', '分析', '生成', '报告']
|
||||
|
||||
/** hero 紫渐变(仅落地页使用)。 */
|
||||
const HERO_GRADIENT: Array<[ResourceColor, number]> = [
|
||||
[COLOR_DIAG_HERO_TOP, 0.0],
|
||||
[COLOR_DIAG_HERO_BOTTOM, 1.0]
|
||||
]
|
||||
|
||||
/** 副标白 85% 透明(ARGB 前缀 D9)。 */
|
||||
const HERO_SUB_TEXT: string = '#D9FFFFFF'
|
||||
|
||||
/** 取不到屏幕尺寸时 hero 的兜底高度。 */
|
||||
const HERO_FALLBACK_HEIGHT: number = 300
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct DiagnosisLandingPage {
|
||||
@State heroHeight: number = HERO_FALLBACK_HEIGHT
|
||||
|
||||
aboutToAppear(): void {
|
||||
Tracker.track('diagnosis.landing_view')
|
||||
// 规范 §5:hero 高 = 屏高 38%。滚动容器内百分比基准不稳定,按物理屏高换算 vp。
|
||||
try {
|
||||
const d = display.getDefaultDisplaySync()
|
||||
this.heroHeight = Math.max(260, this.getUIContext().px2vp(d.height) * 0.38)
|
||||
} catch (_e) {
|
||||
this.heroHeight = HERO_FALLBACK_HEIGHT
|
||||
}
|
||||
}
|
||||
|
||||
private goForm(): void {
|
||||
Tracker.track('diagnosis.cta_click')
|
||||
router.pushUrl({ url: 'pages/DiagnosisFormPage' })
|
||||
.catch((e: Object): void => {
|
||||
console.error(`nav to form failed: ${String(e).substring(0, 80)}`)
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
TopBar() {
|
||||
Row({ space: 8 }) {
|
||||
Text('‹ 返回')
|
||||
.fontSize(15)
|
||||
.fontColor(COLOR_TEXT_SECONDARY)
|
||||
.padding({ left: 4, right: 4, top: 2, bottom: 2 })
|
||||
.onClick(() => {
|
||||
router.back()
|
||||
})
|
||||
Text('美学诊断')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
}
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.padding({ left: 12, right: 16 })
|
||||
.backgroundColor(Color.White)
|
||||
}
|
||||
|
||||
@Builder
|
||||
ProblemCard() {
|
||||
Column({ space: 0 }) {
|
||||
ForEach(PROBLEMS, (p: string) => {
|
||||
Row({ space: 10 }) {
|
||||
Column()
|
||||
.width(8)
|
||||
.height(8)
|
||||
.borderRadius(4)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
Text(p)
|
||||
.fontSize(14)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.constraintSize({ minHeight: 40 })
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}, (p: string) => p)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor(Color.White)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
CtaBar() {
|
||||
Column({ space: 8 }) {
|
||||
Text('我们会保障您的个人信息安全')
|
||||
.fontSize(11)
|
||||
.fontColor(COLOR_TEXT_SECONDARY)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
Button('马上体验')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(Color.White)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.borderRadius(24)
|
||||
.onClick(() => {
|
||||
this.goForm()
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
|
||||
.backgroundColor(Color.White)
|
||||
.border({ width: { top: 0.5 }, color: COLOR_DIVIDER })
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
this.TopBar()
|
||||
Scroll() {
|
||||
Column({ space: 14 }) {
|
||||
// ① hero(全 App 唯一允许出现紫色的区块)
|
||||
Column({ space: 10 }) {
|
||||
Text('个人形象风格分析报告')
|
||||
.fontSize(26)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(Color.White)
|
||||
Text('5 分钟,看清你的穿衣风格')
|
||||
.fontSize(13)
|
||||
.fontColor(HERO_SUB_TEXT)
|
||||
}
|
||||
.width('100%')
|
||||
.height(this.heroHeight)
|
||||
.padding(24)
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.linearGradient({ angle: 180, colors: HERO_GRADIENT })
|
||||
|
||||
// ② 穿衣困扰
|
||||
Text('你的穿衣困扰')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
.width('100%')
|
||||
this.ProblemCard()
|
||||
|
||||
// ③ 四步流程
|
||||
Text('如何生成你的报告')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
.width('100%')
|
||||
StepBar({ steps: STEPS, current: 0 })
|
||||
.width('100%')
|
||||
.padding({ top: 4, bottom: 8, left: 4, right: 4 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 12, bottom: 16 })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
|
||||
// ④ 吸底 CTA(主色玫红,非紫)
|
||||
this.CtaBar()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(COLOR_PAGE_BG)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* HairColorPage —— 换发色独立页(从发型设计页拆出,2026-09 按用户反馈),Phase A 直连线上服务。
|
||||
*
|
||||
* 交互:上传面部特写 → 点选发色(8 色圆点)+ 强度滑杆 → 吸底「换发色」按钮触发(5~10s)
|
||||
* → 图片区「原图/效果图」页签对比(无结果时效果图页签置灰)→ 保存相册。
|
||||
* 每次都在原图上换色(不在结果上叠加),换色效果可预期;失败保留当前图不覆盖。
|
||||
* 每日额度与发型试戴共用 hairstyle key(P0-5 合并计数)。
|
||||
*/
|
||||
|
||||
import { router } from '@kit.ArkUI'
|
||||
import { image } from '@kit.ImageKit'
|
||||
import { photoAccessHelper } from '@kit.MediaLibraryKit'
|
||||
import { fileUri } from '@kit.CoreFileKit'
|
||||
import { TryonApi } from '../core/TryonApi'
|
||||
import { ImageCodec } from '../core/ImageCodec'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
import { DailyLimiter } from '../core/DailyLimiter'
|
||||
import { Tracker } from '../core/Tracker'
|
||||
import { ContextHolder } from '../core/ContextHolder'
|
||||
import {
|
||||
COLOR_PRIMARY,
|
||||
COLOR_PAGE_BG,
|
||||
COLOR_TEXT_PRIMARY,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
COLOR_TEXT_DISABLED,
|
||||
COLOR_DIVIDER,
|
||||
COLOR_IMAGE_BG,
|
||||
COLOR_BADGE_BG
|
||||
} from '../core/Theme'
|
||||
import { ColorDot } from '../components/f2/ColorDot'
|
||||
import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent'
|
||||
import { TaskBonusPanel } from '../components/f1/TaskBonusPanel'
|
||||
|
||||
/** GET /api/v1/quota?quota_key=hairstyle 响应(与发型设计页同一配额池)。 */
|
||||
interface HairQuotaWire {
|
||||
quota_key: string
|
||||
used: number
|
||||
limit_value: number
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
reset_at: string | null
|
||||
}
|
||||
|
||||
interface PresetColor {
|
||||
name: string
|
||||
rgb: number[]
|
||||
}
|
||||
|
||||
const PRESET_COLORS: PresetColor[] = [
|
||||
{ name: '自然黑', rgb: [30, 30, 30] },
|
||||
{ name: '深棕', rgb: [74, 48, 36] },
|
||||
{ name: '栗色', rgb: [128, 70, 42] },
|
||||
{ name: '蜜茶', rgb: [156, 108, 66] },
|
||||
{ name: '亚麻', rgb: [150, 140, 130] },
|
||||
{ name: '酒红', rgb: [128, 32, 48] },
|
||||
{ name: '奶奶灰', rgb: [160, 160, 165] },
|
||||
{ name: '蓝黑', rgb: [40, 50, 70] }
|
||||
]
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct HairColorPage {
|
||||
@State photoUri: string = ''
|
||||
@State generating: boolean = false
|
||||
@State statusText: string = ''
|
||||
@State resultPm: image.PixelMap | undefined = undefined
|
||||
@State resultBase64: string = '' // 保存用它,避免二次编码
|
||||
@State imageTab: number = 0 // 图片区页签:0=原图 1=效果图(无结果时置灰)
|
||||
@State colorIdx: number = 1 // 默认深棕
|
||||
@State ratio: number = 0.9
|
||||
@State showFaceConsent: boolean = false
|
||||
// —— 每日额度(P0-5:与发型试戴共用 hairstyle key,免费档 3 次/天)——
|
||||
@State hairRemaining: number = -1 // -1=未加载(放行);≥0 为 min(服务端, 端侧) 剩余
|
||||
@State showQuotaBlocked: boolean = false
|
||||
@State showTaskPanel: boolean = false
|
||||
private consentPickCamera: boolean = false
|
||||
private imageTabs: string[] = ['原图', '效果图']
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadHairRemaining()
|
||||
}
|
||||
|
||||
/** 额度口径与发型设计页一致:min(服务端, 端侧 DailyLimiter),拉取失败静默用端侧。 */
|
||||
private async loadHairRemaining(): Promise<void> {
|
||||
await DailyLimiter.init()
|
||||
try {
|
||||
const q = await ApiClient.get<HairQuotaWire>('/api/v1/quota?quota_key=hairstyle')
|
||||
DailyLimiter.setBonus('hairstyle', q.bonus_granted)
|
||||
this.hairRemaining = Math.min(q.remaining, DailyLimiter.remaining('hairstyle'))
|
||||
} catch (_e) {
|
||||
this.hairRemaining = DailyLimiter.remaining('hairstyle')
|
||||
}
|
||||
}
|
||||
|
||||
/** RGB 分量转两位 hex(发色圆点展示用)。 */
|
||||
private hex2(v: number): string {
|
||||
const s: string = v.toString(16)
|
||||
return s.length < 2 ? `0${s}` : s
|
||||
}
|
||||
|
||||
/** PRESET_COLORS 的 RGB 转 #RRGGBB:仅圆点展示,发色接口 rgb 传参格式不变。 */
|
||||
private colorHex(rgb: number[]): string {
|
||||
return `#${this.hex2(rgb[0])}${this.hex2(rgb[1])}${this.hex2(rgb[2])}`
|
||||
}
|
||||
|
||||
/** 上传面部特写前须单独同意(CR-01:人脸属敏感个人信息)。 */
|
||||
private pickPhoto(fromCamera: boolean): void {
|
||||
if (!FaceConsentStore.isGranted()) {
|
||||
this.consentPickCamera = fromCamera
|
||||
this.showFaceConsent = true
|
||||
return
|
||||
}
|
||||
this.doPickPhoto(fromCamera)
|
||||
}
|
||||
|
||||
private async doPickPhoto(fromCamera: boolean): Promise<void> {
|
||||
try {
|
||||
const picked = fromCamera ? await ImageCodec.takePhoto() : await ImageCodec.pickFromGallery()
|
||||
if (picked === undefined) {
|
||||
return
|
||||
}
|
||||
this.photoUri = picked.uri
|
||||
this.resultPm = undefined
|
||||
this.resultBase64 = ''
|
||||
this.imageTab = 0
|
||||
this.statusText = ''
|
||||
} catch (e) {
|
||||
this.statusText = e instanceof ApiError ? e.message : '读取照片失败'
|
||||
}
|
||||
}
|
||||
|
||||
private onConsentAgree(): void {
|
||||
FaceConsentStore.grant()
|
||||
this.showFaceConsent = false
|
||||
this.doPickPhoto(this.consentPickCamera)
|
||||
}
|
||||
|
||||
private onConsentRefuse(): void {
|
||||
this.showFaceConsent = false
|
||||
this.statusText = '未同意照片授权,你可以先看看色板'
|
||||
}
|
||||
|
||||
/** 吸底按钮触发:在当前原图上换发色(不在结果上叠加,效果可预期)。 */
|
||||
private async applyColor(): Promise<void> {
|
||||
if (this.photoUri.length === 0 || this.generating) {
|
||||
return
|
||||
}
|
||||
// 每日额度耗尽(P0-5,与试戴合并计数同一 hairstyle key):不发请求,给出路
|
||||
if (this.hairRemaining === 0) {
|
||||
Tracker.track('haircolor.generate', { 'ok': false, 'duration_s': 0, 'blocked': true })
|
||||
this.showQuotaBlocked = true
|
||||
return
|
||||
}
|
||||
const startMs: number = Date.now()
|
||||
this.generating = true
|
||||
const c: PresetColor = PRESET_COLORS[this.colorIdx]
|
||||
this.statusText = `正在换发色(${c.name})…`
|
||||
try {
|
||||
const buf = await ImageCodec.toJpegBuffer(this.photoUri)
|
||||
const rgb: string = `[${c.rgb[0]}, ${c.rgb[1]}, ${c.rgb[2]}]`
|
||||
this.resultBase64 = await TryonApi.changeHairColor(buf, rgb, this.ratio)
|
||||
this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64)
|
||||
this.imageTab = 1 // 成功自动切到效果图页签
|
||||
this.statusText = '换发色完成,点「原图」页签可对比'
|
||||
// 成功才计数(与试戴同一 hairstyle key,合并计算每日额度)
|
||||
DailyLimiter.tryConsume('hairstyle')
|
||||
this.hairRemaining = DailyLimiter.remaining('hairstyle')
|
||||
Tracker.track('haircolor.generate', {
|
||||
'ok': true, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
||||
})
|
||||
} catch (e) {
|
||||
Tracker.track('haircolor.generate', {
|
||||
'ok': false, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
||||
})
|
||||
this.statusText = e instanceof ApiError ? e.message : '换发色失败,请重试'
|
||||
} finally {
|
||||
this.generating = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存到相册:结果 Base64 现场写缓存文件 → 系统保存对话框(取消/失败三态文案)。 */
|
||||
private async saveResult(): Promise<void> {
|
||||
if (this.resultBase64.length === 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const path: string = ImageCodec.base64ToCacheFile(this.resultBase64, `haircolor_${Date.now()}.jpg`)
|
||||
this.statusText = await this.saveFileToGallery(path)
|
||||
} catch (_e) {
|
||||
this.statusText = '保存失败,请重试'
|
||||
}
|
||||
}
|
||||
|
||||
/** 系统保存对话框 → 三态文案(与发型设计页统一口径)。 */
|
||||
private async saveFileToGallery(path: string): Promise<string> {
|
||||
try {
|
||||
const helper = photoAccessHelper.getPhotoAccessHelper(ContextHolder.get())
|
||||
const cfg: photoAccessHelper.PhotoCreationConfig = { fileNameExtension: 'jpg', photoType: photoAccessHelper.PhotoType.IMAGE }
|
||||
const uris: Array<string> = await helper.showAssetsCreationDialog([fileUri.getUriFromPath(path)], [cfg])
|
||||
return uris.length > 0 ? '已保存到相册' : '已取消保存'
|
||||
} catch (_e) {
|
||||
return '保存失败,请重试'
|
||||
}
|
||||
}
|
||||
|
||||
/** 今日额度耗尽的引导 overlay(P0-5):不发请求,给「做任务换额度」出路。 */
|
||||
@Builder
|
||||
QuotaBlockedDialog() {
|
||||
Column() {
|
||||
Column({ space: 14 }) {
|
||||
Text('今日换发色额度已用完')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
Text('明天再来,或做任务多换几次')
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_TEXT_SECONDARY)
|
||||
.textAlign(TextAlign.Center)
|
||||
Row({ space: 12 }) {
|
||||
Button('知道了')
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor(COLOR_IMAGE_BG)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.showQuotaBlocked = false
|
||||
})
|
||||
Button('做任务换额度')
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.showQuotaBlocked = false
|
||||
this.showTaskPanel = true
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
.width('82%')
|
||||
.padding(20)
|
||||
.borderRadius(16)
|
||||
.backgroundColor(Color.White)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
|
||||
/** 「做任务换额度」半屏面板(P0-4):到账后重拉额度。 */
|
||||
@Builder
|
||||
TaskBonusSheet() {
|
||||
TaskBonusPanel({
|
||||
onClaimed: (_quotaKey: string): void => {
|
||||
this.loadHairRemaining()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
Column() {
|
||||
// 顶部标题栏
|
||||
Row() {
|
||||
Text('‹ 返回').fontSize(15).fontColor(COLOR_TEXT_SECONDARY).onClick(() => router.back())
|
||||
Text('换发色').fontSize(17).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY)
|
||||
Text('').width(48)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
|
||||
.justifyContent(FlexAlign.SpaceBetween)
|
||||
.backgroundColor(Color.White)
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
// 图片区页签:原图 | 效果图(无结果时效果图置灰不可点)
|
||||
Row() {
|
||||
ForEach(this.imageTabs, (label: string, idx: number) => {
|
||||
Text(label)
|
||||
.fontSize(13)
|
||||
.fontColor(idx === 1 && this.resultPm === undefined
|
||||
? COLOR_TEXT_DISABLED
|
||||
: (this.imageTab === idx ? Color.White : COLOR_TEXT_PRIMARY))
|
||||
.backgroundColor(this.imageTab === idx && !(idx === 1 && this.resultPm === undefined)
|
||||
? COLOR_PRIMARY
|
||||
: Color.Transparent)
|
||||
.padding({ left: 20, right: 20, top: 7, bottom: 7 })
|
||||
.borderRadius(16)
|
||||
.onClick(() => {
|
||||
if (idx === 1 && this.resultPm === undefined) {
|
||||
return // 无结果:效果图页签不响应
|
||||
}
|
||||
this.imageTab = idx
|
||||
})
|
||||
}, (label: string) => label)
|
||||
}
|
||||
.padding(2)
|
||||
.borderRadius(18)
|
||||
.backgroundColor(COLOR_IMAGE_BG)
|
||||
.alignSelf(ItemAlign.Start)
|
||||
|
||||
// 图片区(单区域,定高 320):无照片=上传入口;生成中=loading;按页签显示原图/效果图。
|
||||
// 注意 Stack 必须显式定高:内部额度胶囊 Row 用 height(100%),不定高会被 Scroll 视口撑飞
|
||||
Stack({ alignContent: Alignment.BottomStart }) {
|
||||
if (this.photoUri.length === 0) {
|
||||
Column({ space: 6 }) {
|
||||
Text('+').fontSize(28).fontColor(COLOR_TEXT_SECONDARY)
|
||||
Text('上传面部特写').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
|
||||
}
|
||||
.width('100%').height(320).justifyContent(FlexAlign.Center)
|
||||
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
|
||||
.onClick(() => { this.pickPhoto(false) })
|
||||
} else if (this.generating) {
|
||||
Column({ space: 8 }) {
|
||||
LoadingProgress().width(36).height(36).color(COLOR_PRIMARY)
|
||||
Text('生成中…').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
|
||||
}
|
||||
.width('100%').height(320).justifyContent(FlexAlign.Center)
|
||||
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
|
||||
} else if (this.imageTab === 1 && this.resultPm !== undefined) {
|
||||
// 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 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
.margin(8)
|
||||
} else {
|
||||
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 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
.margin(8)
|
||||
}
|
||||
|
||||
// 右下「今日还可换发色 N 次」胶囊(P0-5,与试戴共用额度):仅展示不参与命中
|
||||
if (this.hairRemaining >= 0 && this.photoUri.length > 0) {
|
||||
Row() {
|
||||
Text(`今日还可换发色 ${this.hairRemaining} 次`)
|
||||
.fontSize(11).fontColor(Color.White)
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
.justifyContent(FlexAlign.End)
|
||||
.alignItems(VerticalAlign.Bottom)
|
||||
.padding({ right: 8, bottom: 8 })
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height(320)
|
||||
|
||||
// 入口文字链:相册/拍照 + 保存(有结果才可点)
|
||||
Row({ space: 16 }) {
|
||||
Text('相册').fontSize(12).fontColor(COLOR_PRIMARY).onClick(() => { this.pickPhoto(false) })
|
||||
Text('拍照').fontSize(12).fontColor(COLOR_PRIMARY).onClick(() => { this.pickPhoto(true) })
|
||||
Text('保存')
|
||||
.fontSize(12)
|
||||
.fontColor(this.resultPm !== undefined ? COLOR_PRIMARY : COLOR_TEXT_SECONDARY)
|
||||
.onClick(() => { this.saveResult() })
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text('请使用本人照片,上传即确认不侵犯他人肖像权')
|
||||
.fontSize(11).fontColor(COLOR_TEXT_SECONDARY).width('100%')
|
||||
|
||||
if (this.statusText.length > 0) {
|
||||
Text(this.statusText).fontSize(12).fontColor(COLOR_TEXT_SECONDARY).width('100%')
|
||||
}
|
||||
|
||||
// 发色卡:8 色圆点+名称 + 强度滑杆(每次都在原图上换色)
|
||||
Column({ space: 12 }) {
|
||||
Text('选择发色').fontSize(14).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY)
|
||||
.width('100%')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(PRESET_COLORS, (c: PresetColor, idx: number) => {
|
||||
Column() {
|
||||
ColorDot({
|
||||
colorHex: this.colorHex(c.rgb),
|
||||
label: c.name,
|
||||
selected: this.colorIdx === idx,
|
||||
onSelect: (): void => { this.colorIdx = idx }
|
||||
})
|
||||
}
|
||||
.margin({ right: 14, bottom: 12 })
|
||||
}, (c: PresetColor) => c.name)
|
||||
}
|
||||
.width('100%')
|
||||
Row({ space: 8 }) {
|
||||
Text('强度').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
|
||||
Slider({ value: this.ratio, min: 0, max: 1, step: 0.1 })
|
||||
.layoutWeight(1)
|
||||
.onChange((v: number) => { this.ratio = v })
|
||||
Text(this.ratio.toFixed(1)).fontSize(12).fontColor(COLOR_TEXT_SECONDARY).width(28)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(12)
|
||||
.backgroundColor(Color.White)
|
||||
|
||||
// 合并计数脚注(P0-5):与发型试戴共用 hairstyle 每日额度
|
||||
Text('换发色与发型试戴共用每日额度')
|
||||
.fontSize(11).fontColor(COLOR_TEXT_SECONDARY).width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Off)
|
||||
|
||||
// 吸底「换发色」:白底条+顶分隔线,高48圆角24主色;未上传照片或生成中置灰
|
||||
Column() {
|
||||
Divider().strokeWidth(0.5).color(COLOR_DIVIDER)
|
||||
Button(this.generating ? '生成中…' : `换发色(${PRESET_COLORS[this.colorIdx].name})`)
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.borderRadius(24)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.canGenerate() ? Color.White : COLOR_TEXT_DISABLED)
|
||||
.backgroundColor(this.canGenerate() ? COLOR_PRIMARY : COLOR_DIVIDER)
|
||||
.enabled(this.canGenerate())
|
||||
.onClick(() => { this.applyColor() })
|
||||
}
|
||||
.width('100%')
|
||||
.backgroundColor(Color.White)
|
||||
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
if (this.showFaceConsent) {
|
||||
FacePhotoConsent({
|
||||
onAgree: (): void => {
|
||||
this.onConsentAgree()
|
||||
},
|
||||
onRefuse: (): void => {
|
||||
this.onConsentRefuse()
|
||||
}
|
||||
})
|
||||
}
|
||||
if (this.showQuotaBlocked) {
|
||||
this.QuotaBlockedDialog()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(COLOR_PAGE_BG)
|
||||
.bindSheet($$this.showTaskPanel, this.TaskBonusSheet(), {
|
||||
height: '60%',
|
||||
showClose: true
|
||||
})
|
||||
}
|
||||
|
||||
/** 吸底按钮可用条件:已上传照片 + 不在生成中。 */
|
||||
private canGenerate(): boolean {
|
||||
return !this.generating && this.photoUri.length > 0
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,50 @@
|
||||
/**
|
||||
* HairDesignPage —— 发型设计(试戴 + 换发色),Phase A 直连线上服务。
|
||||
* HairDesignPage —— 发型设计(只试戴;换发色已拆到独立页 HairColorPage),Phase A 直连线上服务。
|
||||
*
|
||||
* 流程(产品方案 §2.3):上传面部特写 → 点选发型即试戴(5~10s)→ 可换发色 → 保存相册。
|
||||
* 交互(2026-09 按用户反馈重排):
|
||||
* 图片区单区域展示,顶部「原图/效果图」页签切换(无结果时效果图页签置灰不可点);
|
||||
* 点选发型只选中不生成,吸底「换发型」按钮触发试戴(5~10s)→ 成功自动切到效果图页签;
|
||||
* 保存走系统相册对话框;不再保留「上次结果」回看卡(相册留存即可)。
|
||||
* 异常口径:400 用服务端 message 原样提示;失败保留当前图不覆盖。
|
||||
* 发型库三态(#8/#18):加载中 / 空态 / 失败+重新加载。
|
||||
* 结果归宿(#18,与 #17 统一):试戴/换发色成功即落 filesDir/results/hair_last.jpg
|
||||
* (换发色后覆盖为最新版本),保存优先复用该文件,杀 App 后「上次结果」卡可回看。
|
||||
*/
|
||||
|
||||
import { router } from '@kit.ArkUI'
|
||||
import { image } from '@kit.ImageKit'
|
||||
import { util } from '@kit.ArkTS'
|
||||
import { photoAccessHelper } from '@kit.MediaLibraryKit'
|
||||
import { fileUri } from '@kit.CoreFileKit'
|
||||
import fs from '@ohos.file.fs'
|
||||
import { TryonApi, Hairstyle } from '../core/TryonApi'
|
||||
import { ImageCodec } from '../core/ImageCodec'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
import { DailyLimiter } from '../core/DailyLimiter'
|
||||
import { Tracker } from '../core/Tracker'
|
||||
import { ContextHolder } from '../core/ContextHolder'
|
||||
import { COLOR_PRIMARY } from '../core/Theme'
|
||||
import {
|
||||
COLOR_PRIMARY,
|
||||
COLOR_PAGE_BG,
|
||||
COLOR_TEXT_PRIMARY,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
COLOR_TEXT_DISABLED,
|
||||
COLOR_DIVIDER,
|
||||
COLOR_IMAGE_BG,
|
||||
COLOR_BADGE_BG,
|
||||
COLOR_ERROR
|
||||
} from '../core/Theme'
|
||||
import { Segment } from '../components/f2/Segment'
|
||||
import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent'
|
||||
import { TaskBonusPanel } from '../components/f1/TaskBonusPanel'
|
||||
|
||||
interface PresetColor {
|
||||
name: string
|
||||
rgb: number[]
|
||||
/** GET /api/v1/quota?quota_key=hairstyle 响应(Phase B 配额接入后生效;Phase A 拉取失败静默用端侧)。 */
|
||||
interface HairQuotaWire {
|
||||
quota_key: string
|
||||
used: number
|
||||
limit_value: number
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
reset_at: string | null
|
||||
}
|
||||
|
||||
const PRESET_COLORS: PresetColor[] = [
|
||||
{ name: '自然黑', rgb: [30, 30, 30] },
|
||||
{ name: '深棕', rgb: [74, 48, 36] },
|
||||
{ name: '栗色', rgb: [128, 70, 42] },
|
||||
{ name: '蜜茶', rgb: [156, 108, 66] },
|
||||
{ name: '亚麻', rgb: [150, 140, 130] },
|
||||
{ name: '酒红', rgb: [128, 32, 48] },
|
||||
{ name: '奶奶灰', rgb: [160, 160, 165] },
|
||||
{ name: '蓝黑', rgb: [40, 50, 70] }
|
||||
]
|
||||
|
||||
const PAGE_BG: string = '#F5F6F8'
|
||||
const ACCENT: string = COLOR_PRIMARY
|
||||
const TEXT_PRIMARY: string = '#222222'
|
||||
const TEXT_SECONDARY: string = '#8A9099'
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct HairDesignPage {
|
||||
@@ -53,30 +56,20 @@ struct HairDesignPage {
|
||||
@State generating: boolean = false
|
||||
@State statusText: string = ''
|
||||
@State resultPm: image.PixelMap | undefined = undefined
|
||||
@State resultBase64: string = '' // 换发色/保存都用它,避免二次编码
|
||||
@State resultLocalPath: string = '' // 本机留存副本(#18):保存优先复用
|
||||
@State lastLocalPath: string = '' // 「上次结果」卡数据源(aboutToAppear 回看,#18)
|
||||
@State showOriginal: boolean = false
|
||||
@State colorIdx: number = 1
|
||||
@State ratio: number = 0.9
|
||||
@State resultBase64: string = '' // 保存用它,避免二次编码
|
||||
@State imageTab: number = 0 // 图片区页签:0=原图 1=效果图(无结果时置灰)
|
||||
@State genderTab: string = 'female' // 发型男女分栏(female 默认:库内女款多于男款)
|
||||
@State showFaceConsent: boolean = false
|
||||
// —— 每日试戴额度(P0-5 端侧兜底:免费档 3 次/天,产品方案 §3.2;与换发色共用 hairstyle key)——
|
||||
@State hairRemaining: number = -1 // -1=未加载(放行);≥0 为 min(服务端, 端侧) 剩余
|
||||
@State showQuotaBlocked: boolean = false // 额度耗尽引导 overlay
|
||||
@State showTaskPanel: boolean = false // 「做任务换额度」半屏面板
|
||||
private consentPickCamera: boolean = false // 同意后继续的拍照/相册入口
|
||||
|
||||
/** 本机结果留存路径(#18):filesDir/results(cacheDir 有系统配额会被清)。 */
|
||||
private resultStorePath(): string {
|
||||
return `${ContextHolder.get().filesDir}/results/hair_last.jpg`
|
||||
}
|
||||
private imageTabs: string[] = ['原图', '效果图']
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.loadStyles()
|
||||
// 上次结果回看(#18):沙盒留存最近一次发型结果,杀 App/重启不丢
|
||||
try {
|
||||
if (fs.accessSync(this.resultStorePath())) {
|
||||
this.lastLocalPath = this.resultStorePath()
|
||||
}
|
||||
} catch (_e) {
|
||||
// 目录不存在等:视为无上次结果
|
||||
}
|
||||
this.loadHairRemaining()
|
||||
// 预留:Agent 联动(V2)预选发型,见产品方案 §7
|
||||
const params = router.getParams() as Record<string, Object> | undefined
|
||||
if (params !== undefined && typeof params['hairstyle_id'] === 'string') {
|
||||
@@ -97,6 +90,45 @@ struct HairDesignPage {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 试戴剩余额度(P0-5):端侧 DailyLimiter 为兜底权威(Phase A 直连不经后端),
|
||||
* 服务端配额可拉时取 min(服务端, 端侧);拉取失败静默用端侧值,不阻断功能。
|
||||
*/
|
||||
private async loadHairRemaining(): Promise<void> {
|
||||
await DailyLimiter.init()
|
||||
try {
|
||||
const q = await ApiClient.get<HairQuotaWire>('/api/v1/quota?quota_key=hairstyle')
|
||||
// 奖励加成(迭代 3 修正):服务端 bonus_granted 抬当日端侧上限,再取 min
|
||||
DailyLimiter.setBonus('hairstyle', q.bonus_granted)
|
||||
this.hairRemaining = Math.min(q.remaining, DailyLimiter.remaining('hairstyle'))
|
||||
} catch (_e) {
|
||||
// 拉取失败保持 bonus=0(兜底 3 次不变)
|
||||
this.hairRemaining = DailyLimiter.remaining('hairstyle')
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前 Tab 的发型(unknown 款两 Tab 都展示,服务端新增款不丢)。 */
|
||||
private visibleStyles(): Hairstyle[] {
|
||||
const out: Hairstyle[] = []
|
||||
for (const s of this.styles) {
|
||||
const g: string = TryonApi.hairstyleGender(s.id)
|
||||
if (g === this.genderTab || g === 'unknown') {
|
||||
out.push(s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 当前选中的发型名(吸底按钮文案用);未选中为空串。 */
|
||||
private selectedStyleName(): string {
|
||||
for (const s of this.styles) {
|
||||
if (s.id === this.selectedStyleId) {
|
||||
return s.name
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 上传面部特写前须单独同意(CR-01:人脸属敏感个人信息)。 */
|
||||
private pickPhoto(fromCamera: boolean): void {
|
||||
if (!FaceConsentStore.isGranted()) {
|
||||
@@ -116,8 +148,8 @@ struct HairDesignPage {
|
||||
this.photoUri = picked.uri
|
||||
this.resultPm = undefined
|
||||
this.resultBase64 = ''
|
||||
this.resultLocalPath = ''
|
||||
this.selectedStyleId = ''
|
||||
this.imageTab = 0
|
||||
this.statusText = ''
|
||||
} catch (e) {
|
||||
this.statusText = e instanceof ApiError ? e.message : '读取照片失败'
|
||||
@@ -135,72 +167,59 @@ struct HairDesignPage {
|
||||
this.statusText = '未同意照片授权,你可以先浏览下方发型库'
|
||||
}
|
||||
|
||||
private async tryOn(style: Hairstyle): Promise<void> {
|
||||
if (this.photoUri.length === 0 || this.generating) {
|
||||
/** 吸底按钮触发:对当前选中发型发起试戴(点选网格只选中,不发请求)。 */
|
||||
private async tryOn(): Promise<void> {
|
||||
if (this.photoUri.length === 0 || this.selectedStyleId.length === 0 || this.generating) {
|
||||
return
|
||||
}
|
||||
let style: Hairstyle | undefined = undefined
|
||||
for (const s of this.styles) {
|
||||
if (s.id === this.selectedStyleId) {
|
||||
style = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if (style === undefined) {
|
||||
return
|
||||
}
|
||||
// 每日额度耗尽(P0-5,与换发色合并计数):不发请求,给「做任务换额度」出路
|
||||
if (this.hairRemaining === 0) {
|
||||
Tracker.track('hair.generate', { 'ok': false, 'duration_s': 0, 'blocked': true })
|
||||
this.showQuotaBlocked = true
|
||||
return
|
||||
}
|
||||
const startMs: number = Date.now()
|
||||
this.generating = true
|
||||
this.selectedStyleId = style.id
|
||||
this.showOriginal = false
|
||||
this.statusText = `正在试戴「${style.name}」,约 5~15 秒…`
|
||||
try {
|
||||
const buf = await ImageCodec.toJpegBuffer(this.photoUri)
|
||||
this.resultBase64 = await TryonApi.changeHair(buf, style.id)
|
||||
this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64)
|
||||
this.statusText = '试戴完成,点「对比」看原图'
|
||||
this.persistResult()
|
||||
this.imageTab = 1 // 成功自动切到效果图页签
|
||||
this.statusText = '试戴完成,点「原图」页签可对比'
|
||||
// 成功才计数(失败不占额度):端侧记账 + 埋点上报
|
||||
DailyLimiter.tryConsume('hairstyle')
|
||||
this.hairRemaining = DailyLimiter.remaining('hairstyle')
|
||||
Tracker.track('hair.generate', {
|
||||
'ok': true, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
||||
})
|
||||
} catch (e) {
|
||||
Tracker.track('hair.generate', {
|
||||
'ok': false, 'duration_s': Math.round((Date.now() - startMs) / 1000), 'blocked': false
|
||||
})
|
||||
this.statusText = e instanceof ApiError ? `${e.message}${e.hint ? ':' + e.hint : ''}` : '生成失败,请重试'
|
||||
} finally {
|
||||
this.generating = false
|
||||
}
|
||||
}
|
||||
|
||||
private async changeColor(): Promise<void> {
|
||||
if (this.resultBase64.length === 0 || this.generating) {
|
||||
return
|
||||
}
|
||||
this.generating = true
|
||||
const c: PresetColor = PRESET_COLORS[this.colorIdx]
|
||||
this.statusText = `正在换发色(${c.name})…`
|
||||
try {
|
||||
const helper = new util.Base64Helper()
|
||||
const pure: string = this.resultBase64.includes(',')
|
||||
? this.resultBase64.substring(this.resultBase64.indexOf(',') + 1)
|
||||
: this.resultBase64
|
||||
const buf = helper.decodeSync(pure).buffer as ArrayBuffer
|
||||
const rgb: string = `[${c.rgb[0]}, ${c.rgb[1]}, ${c.rgb[2]}]`
|
||||
this.resultBase64 = await TryonApi.changeHairColor(buf, rgb, this.ratio)
|
||||
this.resultPm = await ImageCodec.base64ToPixelMap(this.resultBase64)
|
||||
this.statusText = '换发色完成'
|
||||
this.persistResult() // 落盘覆盖为最新发色版本(#18 验收④)
|
||||
} catch (e) {
|
||||
this.statusText = e instanceof ApiError ? e.message : '换发色失败,请重试'
|
||||
} finally {
|
||||
this.generating = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 结果留存(#18):当前结果 Base64 落 filesDir/results/hair_last.jpg;失败不阻断展示。 */
|
||||
private persistResult(): void {
|
||||
try {
|
||||
const path: string = ImageCodec.base64ToPersistFile(this.resultBase64, 'hair_last.jpg')
|
||||
this.resultLocalPath = path
|
||||
this.lastLocalPath = path
|
||||
} catch (_e) {
|
||||
this.resultLocalPath = '' // 落盘失败:保存时走 base64 现场写缓存兜底
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存到相册(#18):优先复用本机留存文件(一次写、双用途);留存失败过的现场写缓存兜底。 */
|
||||
/** 保存到相册:结果 Base64 现场写缓存文件 → 系统保存对话框(取消/失败三态文案)。 */
|
||||
private async saveResult(): Promise<void> {
|
||||
if (this.resultBase64.length === 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const path: string = this.resultLocalPath.length > 0
|
||||
? this.resultLocalPath
|
||||
: ImageCodec.base64ToCacheFile(this.resultBase64, `hair_${Date.now()}.jpg`)
|
||||
const path: string = ImageCodec.base64ToCacheFile(this.resultBase64, `hair_${Date.now()}.jpg`)
|
||||
this.statusText = await this.saveFileToGallery(path)
|
||||
} catch (_e) {
|
||||
this.statusText = '保存失败,请重试'
|
||||
@@ -222,12 +241,61 @@ struct HairDesignPage {
|
||||
}
|
||||
}
|
||||
|
||||
/** 「上次结果」卡的保存(#18):直接走本机副本,不依赖网络。 */
|
||||
private async saveLastResult(): Promise<void> {
|
||||
if (this.lastLocalPath.length === 0) {
|
||||
return
|
||||
/** 今日试戴额度耗尽的引导 overlay(P0-5):不发请求,给「做任务换额度」出路。 */
|
||||
@Builder
|
||||
QuotaBlockedDialog() {
|
||||
Column() {
|
||||
Column({ space: 14 }) {
|
||||
Text('今日试戴额度已用完')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
Text('明天再来,或做任务多换几次')
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_TEXT_SECONDARY)
|
||||
.textAlign(TextAlign.Center)
|
||||
Row({ space: 12 }) {
|
||||
Button('知道了')
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor(COLOR_IMAGE_BG)
|
||||
.fontColor(COLOR_TEXT_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.showQuotaBlocked = false
|
||||
})
|
||||
Button('做任务换额度')
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.showQuotaBlocked = false
|
||||
this.showTaskPanel = true
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
.width('82%')
|
||||
.padding(20)
|
||||
.borderRadius(16)
|
||||
.backgroundColor(Color.White)
|
||||
}
|
||||
this.statusText = await this.saveFileToGallery(this.lastLocalPath)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
|
||||
/** 「做任务换额度」半屏面板(P0-4):到账后重拉试戴额度(服务端 Phase B 接入后生效)。 */
|
||||
@Builder
|
||||
TaskBonusSheet() {
|
||||
TaskBonusPanel({
|
||||
onClaimed: (_quotaKey: string): void => {
|
||||
this.loadHairRemaining()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
@@ -235,8 +303,8 @@ struct HairDesignPage {
|
||||
Column() {
|
||||
// 顶部标题栏
|
||||
Row() {
|
||||
Text('‹ 返回').fontSize(15).fontColor(TEXT_SECONDARY).onClick(() => router.back())
|
||||
Text('发型设计').fontSize(17).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY)
|
||||
Text('‹ 返回').fontSize(15).fontColor(COLOR_TEXT_SECONDARY).onClick(() => router.back())
|
||||
Text('发型设计').fontSize(17).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY)
|
||||
Text('').width(48)
|
||||
}
|
||||
.width('100%')
|
||||
@@ -245,199 +313,211 @@ struct HairDesignPage {
|
||||
.backgroundColor(Color.White)
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 12 }) {
|
||||
// 照片区:左原图 / 右结果
|
||||
Row({ space: 12 }) {
|
||||
Column({ space: 8 }) {
|
||||
if (this.photoUri.length > 0) {
|
||||
Image(this.photoUri)
|
||||
.width('100%').height(220).objectFit(ImageFit.Cover).borderRadius(12)
|
||||
} else {
|
||||
Column({ space: 6 }) {
|
||||
Text('+').fontSize(28).fontColor(TEXT_SECONDARY)
|
||||
Text('上传面部特写').fontSize(12).fontColor(TEXT_SECONDARY)
|
||||
}
|
||||
.width('100%').height(220).justifyContent(FlexAlign.Center)
|
||||
.borderRadius(12).backgroundColor('#EDEFF3')
|
||||
.onClick(() => { this.pickPhoto(false) })
|
||||
}
|
||||
Row({ space: 8 }) {
|
||||
Text('相册').fontSize(12).fontColor(ACCENT).onClick(() => { this.pickPhoto(false) })
|
||||
Text('拍照').fontSize(12).fontColor(ACCENT).onClick(() => { this.pickPhoto(true) })
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
|
||||
Column({ space: 8 }) {
|
||||
if (this.generating) {
|
||||
Column({ space: 8 }) {
|
||||
LoadingProgress().width(36).height(36).color(ACCENT)
|
||||
Text('生成中…').fontSize(12).fontColor(TEXT_SECONDARY)
|
||||
}
|
||||
.width('100%').height(220).justifyContent(FlexAlign.Center)
|
||||
.borderRadius(12).backgroundColor('#EDEFF3')
|
||||
} else if (this.resultPm !== undefined && !this.showOriginal) {
|
||||
Image(this.resultPm)
|
||||
.width('100%').height(220).objectFit(ImageFit.Cover).borderRadius(12)
|
||||
} else if (this.showOriginal && this.photoUri.length > 0) {
|
||||
Image(this.photoUri)
|
||||
.width('100%').height(220).objectFit(ImageFit.Cover).borderRadius(12)
|
||||
} else {
|
||||
Column() {
|
||||
Text('效果图').fontSize(12).fontColor(TEXT_SECONDARY)
|
||||
}
|
||||
.width('100%').height(220).justifyContent(FlexAlign.Center)
|
||||
.borderRadius(12).backgroundColor('#EDEFF3')
|
||||
}
|
||||
Row({ space: 8 }) {
|
||||
Text(this.showOriginal ? '看效果' : '对比')
|
||||
.fontSize(12)
|
||||
.fontColor(this.resultPm !== undefined ? ACCENT : TEXT_SECONDARY)
|
||||
Column({ space: 12 }) {
|
||||
// 图片区页签:原图 | 效果图(无结果时效果图置灰不可点)
|
||||
Row() {
|
||||
ForEach(this.imageTabs, (label: string, idx: number) => {
|
||||
Text(label)
|
||||
.fontSize(13)
|
||||
.fontColor(idx === 1 && this.resultPm === undefined
|
||||
? COLOR_TEXT_DISABLED
|
||||
: (this.imageTab === idx ? Color.White : COLOR_TEXT_PRIMARY))
|
||||
.backgroundColor(this.imageTab === idx && !(idx === 1 && this.resultPm === undefined)
|
||||
? COLOR_PRIMARY
|
||||
: Color.Transparent)
|
||||
.padding({ left: 20, right: 20, top: 7, bottom: 7 })
|
||||
.borderRadius(16)
|
||||
.onClick(() => {
|
||||
if (this.resultPm !== undefined) {
|
||||
this.showOriginal = !this.showOriginal
|
||||
if (idx === 1 && this.resultPm === undefined) {
|
||||
return // 无结果:效果图页签不响应
|
||||
}
|
||||
this.imageTab = idx
|
||||
})
|
||||
Text('保存')
|
||||
.fontSize(12)
|
||||
.fontColor(this.resultPm !== undefined ? ACCENT : TEXT_SECONDARY)
|
||||
.onClick(() => { this.saveResult() })
|
||||
}
|
||||
}, (label: string) => label)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(2)
|
||||
.borderRadius(18)
|
||||
.backgroundColor(COLOR_IMAGE_BG)
|
||||
.alignSelf(ItemAlign.Start)
|
||||
|
||||
Text('请使用本人照片,上传即确认不侵犯他人肖像权')
|
||||
.fontSize(11).fontColor(TEXT_SECONDARY).width('100%')
|
||||
|
||||
if (this.statusText.length > 0) {
|
||||
Text(this.statusText).fontSize(12).fontColor(TEXT_SECONDARY).width('100%')
|
||||
}
|
||||
|
||||
// 上次发型结果回看(#18):无本次结果时展示留存副本(保存/移除)
|
||||
if (this.lastLocalPath.length > 0 && this.resultPm === undefined) {
|
||||
Column({ space: 8 }) {
|
||||
Text('上次发型结果')
|
||||
.fontSize(13).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY).width('100%')
|
||||
Image(fileUri.getUriFromPath(this.lastLocalPath))
|
||||
.width('100%').height(280).objectFit(ImageFit.Contain)
|
||||
.borderRadius(12).backgroundColor('#EDEFF3')
|
||||
Row({ space: 16 }) {
|
||||
Text('保存到相册').fontSize(13).fontColor(ACCENT)
|
||||
.onClick(() => { this.saveLastResult() })
|
||||
Text('知道了').fontSize(13).fontColor(TEXT_SECONDARY)
|
||||
.onClick(() => { this.lastLocalPath = '' })
|
||||
}
|
||||
.width('100%').justifyContent(FlexAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(12)
|
||||
.backgroundColor(Color.White)
|
||||
}
|
||||
|
||||
// 发型网格
|
||||
Text('选择发型(点选即试戴)').fontSize(14).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY)
|
||||
if (this.styles.length > 0) {
|
||||
Grid() {
|
||||
ForEach(this.styles, (style: Hairstyle) => {
|
||||
GridItem() {
|
||||
Column({ space: 4 }) {
|
||||
Image(TryonApi.hairstyleImageUrl(style))
|
||||
.width('100%').aspectRatio(0.72).objectFit(ImageFit.Cover)
|
||||
.borderRadius(8)
|
||||
.border(this.selectedStyleId === style.id
|
||||
? { width: 2, color: ACCENT }
|
||||
: { width: 0 })
|
||||
Text(style.name).fontSize(11).fontColor(TEXT_PRIMARY).maxLines(1)
|
||||
}
|
||||
// 图片区(单区域,定高 320):无照片=上传入口;生成中=loading;按页签显示原图/效果图。
|
||||
// 注意 Stack 必须显式定高:内部额度胶囊 Row 用 height(100%),不定高会被 Scroll 视口撑飞
|
||||
Stack({ alignContent: Alignment.BottomStart }) {
|
||||
if (this.photoUri.length === 0) {
|
||||
Column({ space: 6 }) {
|
||||
Text('+').fontSize(28).fontColor(COLOR_TEXT_SECONDARY)
|
||||
Text('上传面部特写').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
|
||||
}
|
||||
.onClick(() => { this.tryOn(style) })
|
||||
}, (style: Hairstyle) => style.id)
|
||||
}
|
||||
.columnsTemplate('1fr 1fr 1fr')
|
||||
.columnsGap(8)
|
||||
.rowsGap(8)
|
||||
.width('100%')
|
||||
} else if (this.listError.length > 0) {
|
||||
// 失败态(#8/#18):错误文案 + 重新加载出口
|
||||
Column({ space: 8 }) {
|
||||
Text(this.listError).fontSize(12).fontColor('#C0392B')
|
||||
Text('重新加载')
|
||||
.fontSize(13).fontColor(ACCENT)
|
||||
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
|
||||
.borderRadius(14).backgroundColor(Color.White)
|
||||
.onClick(() => { this.loadStyles() })
|
||||
.width('100%').height(320).justifyContent(FlexAlign.Center)
|
||||
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
|
||||
.onClick(() => { this.pickPhoto(false) })
|
||||
} else if (this.generating) {
|
||||
Column({ space: 8 }) {
|
||||
LoadingProgress().width(36).height(36).color(COLOR_PRIMARY)
|
||||
Text('生成中…').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
|
||||
}
|
||||
.width('100%').height(320).justifyContent(FlexAlign.Center)
|
||||
.borderRadius(12).backgroundColor(COLOR_IMAGE_BG)
|
||||
} else if (this.imageTab === 1 && this.resultPm !== undefined) {
|
||||
// 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 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
.margin(8)
|
||||
} else {
|
||||
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 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
.margin(8)
|
||||
}
|
||||
|
||||
// 右下「今日还可试戴 N 次」胶囊(P0-5,与换发色共用额度):仅展示不参与命中
|
||||
if (this.hairRemaining >= 0 && this.photoUri.length > 0) {
|
||||
Row() {
|
||||
Text(`今日还可试戴 ${this.hairRemaining} 次`)
|
||||
.fontSize(11).fontColor(Color.White)
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.borderRadius(12)
|
||||
.backgroundColor(COLOR_BADGE_BG)
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
.justifyContent(FlexAlign.End)
|
||||
.alignItems(VerticalAlign.Bottom)
|
||||
.padding({ right: 8, bottom: 8 })
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
} else if (this.listLoaded) {
|
||||
// 空态(#18):加载成功但无上架发型
|
||||
Column({ space: 8 }) {
|
||||
Text('发型暂未上架,稍后再试').fontSize(12).fontColor(TEXT_SECONDARY)
|
||||
Text('重新加载')
|
||||
.fontSize(13).fontColor(ACCENT)
|
||||
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
|
||||
.borderRadius(14).backgroundColor(Color.White)
|
||||
.onClick(() => { this.loadStyles() })
|
||||
.height(320)
|
||||
|
||||
// 入口文字链:相册/拍照 + 保存(有结果才可点)
|
||||
Row({ space: 16 }) {
|
||||
Text('相册').fontSize(12).fontColor(COLOR_PRIMARY).onClick(() => { this.pickPhoto(false) })
|
||||
Text('拍照').fontSize(12).fontColor(COLOR_PRIMARY).onClick(() => { this.pickPhoto(true) })
|
||||
Text('保存')
|
||||
.fontSize(12)
|
||||
.fontColor(this.resultPm !== undefined ? COLOR_PRIMARY : COLOR_TEXT_SECONDARY)
|
||||
.onClick(() => { this.saveResult() })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
} else {
|
||||
// 加载中(#18):不再让空列表永远停在「加载中」
|
||||
Row({ space: 8 }) {
|
||||
LoadingProgress().width(18).height(18).color(ACCENT)
|
||||
Text('发型加载中…').fontSize(12).fontColor(TEXT_SECONDARY)
|
||||
|
||||
Text('请使用本人照片,上传即确认不侵犯他人肖像权')
|
||||
.fontSize(11).fontColor(COLOR_TEXT_SECONDARY).width('100%')
|
||||
|
||||
if (this.statusText.length > 0) {
|
||||
Text(this.statusText).fontSize(12).fontColor(COLOR_TEXT_SECONDARY).width('100%')
|
||||
}
|
||||
|
||||
// 发型网格:点选只选中(描边),吸底「换发型」按钮触发试戴
|
||||
Text('选择发型').fontSize(14).fontWeight(FontWeight.Medium).fontColor(COLOR_TEXT_PRIMARY)
|
||||
Row() {
|
||||
Segment({
|
||||
items: ['女生', '男生'],
|
||||
selected: this.genderTab === 'female' ? 0 : 1,
|
||||
onSelect: (idx: number): void => {
|
||||
this.genderTab = idx === 0 ? 'female' : 'male'
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
}
|
||||
|
||||
// 换发色(有结果才可用)
|
||||
if (this.resultPm !== undefined) {
|
||||
Column({ space: 8 }) {
|
||||
Text('换个发色').fontSize(14).fontWeight(FontWeight.Medium).fontColor(TEXT_PRIMARY)
|
||||
.width('100%')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(PRESET_COLORS, (c: PresetColor, idx: number) => {
|
||||
Text(c.name)
|
||||
.fontSize(12)
|
||||
.fontColor(this.colorIdx === idx ? Color.White : TEXT_PRIMARY)
|
||||
.backgroundColor(this.colorIdx === idx ? ACCENT : '#EDEFF3')
|
||||
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
|
||||
.borderRadius(12)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
.onClick(() => { this.colorIdx = idx })
|
||||
})
|
||||
if (this.visibleStyles().length > 0) {
|
||||
Grid() {
|
||||
ForEach(this.visibleStyles(), (style: Hairstyle) => {
|
||||
GridItem() {
|
||||
Column({ space: 4 }) {
|
||||
Image(TryonApi.hairstyleImageUrl(style))
|
||||
.width('100%').aspectRatio(0.72).objectFit(ImageFit.Cover)
|
||||
.borderRadius(8)
|
||||
.border(this.selectedStyleId === style.id
|
||||
? { width: 2, color: COLOR_PRIMARY }
|
||||
: { width: 0 })
|
||||
Text(style.name).fontSize(11).fontColor(COLOR_TEXT_PRIMARY).maxLines(1)
|
||||
}
|
||||
}
|
||||
.onClick(() => {
|
||||
this.selectedStyleId = style.id // 只选中不生成,底部按钮触发
|
||||
})
|
||||
}, (style: Hairstyle) => style.id)
|
||||
}
|
||||
.columnsTemplate('1fr 1fr 1fr')
|
||||
.columnsGap(8)
|
||||
.rowsGap(8)
|
||||
.width('100%')
|
||||
} else if (this.listError.length > 0) {
|
||||
// 失败态(#8/#18):错误文案 + 重新加载出口
|
||||
Column({ space: 8 }) {
|
||||
Text(this.listError).fontSize(12).fontColor(COLOR_ERROR)
|
||||
Text('重新加载')
|
||||
.fontSize(13).fontColor(COLOR_PRIMARY)
|
||||
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
|
||||
.borderRadius(14).backgroundColor(Color.White)
|
||||
.onClick(() => { this.loadStyles() })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
} else if (this.listLoaded) {
|
||||
// 空态(#18):加载成功但无上架发型
|
||||
Column({ space: 8 }) {
|
||||
Text('发型暂未上架,稍后再试').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
|
||||
Text('重新加载')
|
||||
.fontSize(13).fontColor(COLOR_PRIMARY)
|
||||
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
|
||||
.borderRadius(14).backgroundColor(Color.White)
|
||||
.onClick(() => { this.loadStyles() })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
} else {
|
||||
// 加载中(#18):不再让空列表永远停在「加载中」
|
||||
Row({ space: 8 }) {
|
||||
Text('强度').fontSize(12).fontColor(TEXT_SECONDARY)
|
||||
Slider({ value: this.ratio, min: 0, max: 1, step: 0.1 })
|
||||
.layoutWeight(1)
|
||||
.onChange((v: number) => { this.ratio = v })
|
||||
Text(this.ratio.toFixed(1)).fontSize(12).fontColor(TEXT_SECONDARY).width(28)
|
||||
LoadingProgress().width(18).height(18).color(COLOR_PRIMARY)
|
||||
Text('发型加载中…').fontSize(12).fontColor(COLOR_TEXT_SECONDARY)
|
||||
}
|
||||
Button(this.generating ? '生成中…' : '应用发色')
|
||||
.width('100%')
|
||||
.backgroundColor(ACCENT)
|
||||
.enabled(!this.generating && this.resultBase64.length > 0)
|
||||
.onClick(() => { this.changeColor() })
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(12)
|
||||
.backgroundColor(Color.White)
|
||||
|
||||
// 合并计数脚注(P0-5):试戴与换发色页共用 hairstyle 每日额度
|
||||
Text('试戴与换发色共用每日额度')
|
||||
.fontSize(11).fontColor(COLOR_TEXT_SECONDARY).width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Off)
|
||||
|
||||
// 吸底「换发型」:白底条+顶分隔线,高48圆角24主色;未选照片/发型或生成中置灰
|
||||
Column() {
|
||||
Divider().strokeWidth(0.5).color(COLOR_DIVIDER)
|
||||
Button(this.generating
|
||||
? '生成中…'
|
||||
: (this.selectedStyleName().length > 0 ? `试戴「${this.selectedStyleName()}」` : '换发型'))
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.borderRadius(24)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.canGenerate() ? Color.White : COLOR_TEXT_DISABLED)
|
||||
.backgroundColor(this.canGenerate() ? COLOR_PRIMARY : COLOR_DIVIDER)
|
||||
.enabled(this.canGenerate())
|
||||
.onClick(() => { this.tryOn() })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Off)
|
||||
.backgroundColor(Color.White)
|
||||
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
@@ -452,9 +532,21 @@ struct HairDesignPage {
|
||||
}
|
||||
})
|
||||
}
|
||||
if (this.showQuotaBlocked) {
|
||||
this.QuotaBlockedDialog()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(PAGE_BG)
|
||||
.backgroundColor(COLOR_PAGE_BG)
|
||||
.bindSheet($$this.showTaskPanel, this.TaskBonusSheet(), {
|
||||
height: '60%',
|
||||
showClose: true
|
||||
})
|
||||
}
|
||||
|
||||
/** 吸底按钮可用条件:已上传照片 + 已选发型 + 不在生成中。 */
|
||||
private canGenerate(): boolean {
|
||||
return !this.generating && this.photoUri.length > 0 && this.selectedStyleId.length > 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,18 @@
|
||||
* (组件级复用允许 import,独立路由页无底部 Tab 会把用户锁死在页面里)
|
||||
* - 请求只走 ApiClient;今日额度失败静默降级为 '—'(首页不打扰用户,不弹错误态)
|
||||
* - 响应 wire 类型用具名 interface(字段对齐后端 JSON);泛型调用显式类型参数
|
||||
* - 可选路由参数 initialTab(number 0~3):外部页面可指定落地 Tab
|
||||
* (如报告结果页「查看历史报告」传 1 落地「报告」Tab)
|
||||
*/
|
||||
|
||||
import { router } from '@kit.ArkUI'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { isNetworkError } from '../core/ApiTypes'
|
||||
import { Tracker } from '../core/Tracker'
|
||||
import { COLOR_PRIMARY } from '../core/Theme'
|
||||
import { ProfileContent } from '../components/f1/ProfileContent'
|
||||
import { ReportListTab } from '../components/f3/ReportListTab'
|
||||
import { NetworkBanner } from '../components/f2/NetworkBanner'
|
||||
|
||||
/** GET /api/v1/quota?quota_key=agent_chat 的响应(字段名对齐后端 JSON)。 */
|
||||
interface QuotaWireResp {
|
||||
@@ -40,15 +45,29 @@ struct HomePage {
|
||||
@State currentTabIndex: number = 0
|
||||
@State dateLine: string = ''
|
||||
@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
|
||||
}
|
||||
@@ -65,12 +84,18 @@ struct HomePage {
|
||||
return `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日 ${WEEKDAYS[now.getDay()]}`
|
||||
}
|
||||
|
||||
/** 拉今日 AI 对话额度;失败静默降级为 '—'(首页额度只是辅助信息,不值得打断用户)。 */
|
||||
/** 拉今日 AI 对话额度;网络错误上横幅给重试(P0-6),其他失败静默降级为 '—'。 */
|
||||
private async loadQuota(): Promise<void> {
|
||||
this.quotaNetError = false
|
||||
try {
|
||||
const data = await ApiClient.get<QuotaWireResp>(QUOTA_PATH)
|
||||
this.quotaRemaining = `${data.remaining}`
|
||||
} catch (_e) {
|
||||
} catch (e) {
|
||||
if (isNetworkError(e)) {
|
||||
// 重试有意义的错误:配额卡位置换 NetworkBanner,不再静默
|
||||
this.quotaNetError = true
|
||||
return
|
||||
}
|
||||
this.quotaRemaining = '—'
|
||||
}
|
||||
}
|
||||
@@ -91,9 +116,9 @@ struct HomePage {
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
|
||||
/** 首页功能入口卡:图标块 + 标题/副标题 + 右箭头,整卡点击跳 targetUrl。 */
|
||||
/** 首页功能入口卡:图标块 + 标题/副标题 + 右箭头,整卡点击跳 targetUrl(埋点 home.entry_click)。 */
|
||||
@Builder
|
||||
EntryCard(title: string, subtitle: string, icon: string, color: string, targetUrl: string) {
|
||||
EntryCard(title: string, subtitle: string, icon: string, color: string, targetUrl: string, target: string) {
|
||||
Row({ space: 12 }) {
|
||||
Text(icon)
|
||||
.fontSize(20)
|
||||
@@ -124,6 +149,7 @@ struct HomePage {
|
||||
.backgroundColor(Color.White)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
Tracker.track('home.entry_click', { 'target': target })
|
||||
router.pushUrl({ url: targetUrl })
|
||||
})
|
||||
}
|
||||
@@ -157,7 +183,7 @@ struct HomePage {
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
/** Tab1 首页:问候区 + 两张功能入口卡 + 今日额度小卡。 */
|
||||
/** Tab1 首页:问候区 + 功能入口卡 + 今日额度小卡。 */
|
||||
@Builder
|
||||
HomeTab() {
|
||||
Scroll() {
|
||||
@@ -175,11 +201,22 @@ struct HomePage {
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ top: 8 })
|
||||
|
||||
this.EntryCard('生成我的形象报告', '体型 · 体态 · 穿搭建议,一次看全', '▣', ACCENT, 'pages/ReportCreatePage')
|
||||
this.EntryCard('试衣间', '上传照片,换装试穿', '◈', '#E8912D', 'pages/TryonPage')
|
||||
this.EntryCard('发型设计', '60 款发型试戴,还能换发色', '✂', '#2DB38E', 'pages/HairDesignPage')
|
||||
this.EntryCard('AI 穿搭顾问', '对话式穿搭建议,随时问', '✦', ACCENT_AI, 'pages/ChatPage')
|
||||
this.QuotaCard()
|
||||
this.EntryCard('美学诊断', '个人形象风格分析报告', '▣', ACCENT, 'pages/DiagnosisLandingPage', 'diagnosis')
|
||||
this.EntryCard('美搭配', '我的衣橱,换装试穿', '◈', '#E8912D', 'pages/FittingRoomPage', 'fitting')
|
||||
this.EntryCard('发型设计', '60 款发型,点选试戴', '✂', '#2DB38E', 'pages/HairDesignPage', 'hair')
|
||||
this.EntryCard('换发色', '8 种发色,一键换着看', '◐', '#8E5BD3', 'pages/HairColorPage', 'haircolor')
|
||||
this.EntryCard('AI 穿搭顾问', '对话式穿搭建议,随时问', '✦', ACCENT_AI, 'pages/ChatPage', 'chat')
|
||||
if (this.quotaNetError) {
|
||||
// P0-6:额度拉取遇网络错误——横幅换占配额卡位置,重试可恢复
|
||||
NetworkBanner({
|
||||
onRetry: (): void => {
|
||||
Tracker.track('network.banner_retry', { 'page': 'home' })
|
||||
this.loadQuota()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.QuotaCard()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
@@ -226,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,
|
||||
|
||||
@@ -1,29 +1,36 @@
|
||||
/**
|
||||
* ReportCreatePage —— 报告创建(F3):问卷六字段 + 模拟照片键 + 提交 + 任务轮询。
|
||||
* ReportCreatePage —— 报告创建(F3):必选照片 + 问卷六字段 + 提交 + 任务轮询。
|
||||
*
|
||||
* 流程:
|
||||
* 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验;
|
||||
* 1) 个人照片(必选,正面全身照):先走 FacePhotoConsent 人脸单独授权(CR-01),
|
||||
* 再相册/拍照;提交时先上传(POST /uploads/direct,本地磁盘降级路线)
|
||||
* 拿真实 object_key,再建报告——模拟键已被服务端存在性校验封死
|
||||
* 2) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验;
|
||||
* 身高/体重为滚轮选择器(showTextPickerDialog),不提供键盘输入
|
||||
* 2) 照片键为模拟:V1 骨架 COS 直传未接入,留空自动生成 report_photo/{userId}/{随机}.jpg
|
||||
* 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'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
import { AuthStore } from '../core/AuthStore'
|
||||
import { ImageCodec } from '../core/ImageCodec'
|
||||
import { UploadApi } from '../core/UploadApi'
|
||||
import { Tracker } from '../core/Tracker'
|
||||
import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme'
|
||||
import { TaskPollingPanel } from '../components/f3/TaskPollingPanel'
|
||||
import { FacePhotoConsent, FaceConsentStore } from '../components/f2/FacePhotoConsent'
|
||||
import {
|
||||
ReportCreateResp,
|
||||
ReportListItemWire,
|
||||
buildMockPhotoKey,
|
||||
formatDate,
|
||||
randomHex
|
||||
} from '../components/f3/ReportTypes'
|
||||
@@ -32,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
|
||||
@@ -78,7 +93,10 @@ struct ReportCreatePage {
|
||||
@State birthday: Date = new Date(2000, 0, 15)
|
||||
@State coreGoals: string[] = []
|
||||
@State psychStyle: string = ''
|
||||
@State photoKeyInput: string = ''
|
||||
@State photoUri: string = '' // 已选照片(媒体 URI);空 = 未选,提交必校验
|
||||
@State photoErr: string = ''
|
||||
@State showFaceConsent: boolean = false
|
||||
@State showPhotoSource: boolean = false
|
||||
@State genderErr: string = ''
|
||||
@State heightErr: string = ''
|
||||
@State weightErr: string = ''
|
||||
@@ -105,10 +123,85 @@ struct ReportCreatePage {
|
||||
if (cached !== undefined && cached !== '') {
|
||||
this.lastReportId = cached
|
||||
}
|
||||
// 先恢复本页草稿(跨页面保留已填内容),再应用路由参数:
|
||||
// 美学诊断填写页(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 === '' ? '请上传个人照片' : ''
|
||||
if (this.photoErr !== '') {
|
||||
ok = false
|
||||
}
|
||||
this.genderErr = this.gender === '' ? '请选择性别' : ''
|
||||
if (this.genderErr !== '') {
|
||||
ok = false
|
||||
@@ -141,6 +234,7 @@ struct ReportCreatePage {
|
||||
this.coreGoals.push(g)
|
||||
}
|
||||
this.goalsErr = ''
|
||||
this.saveDraft()
|
||||
}
|
||||
|
||||
private isSelectedGoal(g: string): boolean {
|
||||
@@ -165,6 +259,7 @@ struct ReportCreatePage {
|
||||
if (idx >= 0 && idx < HEIGHT_OPTIONS.length) {
|
||||
this.heightInput = HEIGHT_OPTIONS[idx]
|
||||
this.heightErr = ''
|
||||
this.saveDraft()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -182,11 +277,50 @@ struct ReportCreatePage {
|
||||
if (idx >= 0 && idx < WEIGHT_OPTIONS.length) {
|
||||
this.weightInput = WEIGHT_OPTIONS[idx]
|
||||
this.weightErr = ''
|
||||
this.saveDraft()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 照片入口:人脸属敏感个人信息,上传前须单独同意(CR-01 / FR-ACC-08);
|
||||
* 已授权则直接弹来源选择(相册/拍照)。
|
||||
*/
|
||||
private onPhotoTap(): void {
|
||||
this.photoErr = ''
|
||||
if (!FaceConsentStore.isGranted()) {
|
||||
this.showFaceConsent = true
|
||||
return
|
||||
}
|
||||
this.showPhotoSource = true
|
||||
}
|
||||
|
||||
private onConsentAgree(): void {
|
||||
FaceConsentStore.grant()
|
||||
this.showFaceConsent = false
|
||||
this.showPhotoSource = true
|
||||
}
|
||||
|
||||
private onConsentRefuse(): void {
|
||||
this.showFaceConsent = false
|
||||
this.photoErr = '需同意照片授权后才能上传'
|
||||
}
|
||||
|
||||
private async doPickPhoto(fromCamera: boolean): Promise<void> {
|
||||
this.showPhotoSource = false
|
||||
try {
|
||||
const picked = fromCamera ? await ImageCodec.takePhoto() : await ImageCodec.pickFromGallery()
|
||||
if (picked === undefined) {
|
||||
return // 用户取消:保持原状
|
||||
}
|
||||
this.photoUri = picked.uri
|
||||
this.photoErr = ''
|
||||
} catch (e) {
|
||||
this.photoErr = e instanceof ApiError ? e.message : '读取照片失败,请重试'
|
||||
}
|
||||
}
|
||||
|
||||
private onSubmitTap(): void {
|
||||
if (this.submitting) {
|
||||
return
|
||||
@@ -204,7 +338,7 @@ struct ReportCreatePage {
|
||||
this.openQuotaConfirm()
|
||||
}
|
||||
|
||||
/** 打开配额确认框并拉取当前剩余(首份报告为终身 1 次权益)。 */
|
||||
/** 打开配额确认框并拉取当前剩余(report_basic 终身配额)。 */
|
||||
private openQuotaConfirm(): void {
|
||||
this.showQuotaConfirm = true
|
||||
this.quotaLoaded = false
|
||||
@@ -226,6 +360,9 @@ struct ReportCreatePage {
|
||||
|
||||
private missingLabels(): string[] {
|
||||
const labels: string[] = []
|
||||
if (this.photoErr !== '') {
|
||||
labels.push('个人照片')
|
||||
}
|
||||
if (this.genderErr !== '') {
|
||||
labels.push('性别')
|
||||
}
|
||||
@@ -246,7 +383,9 @@ struct ReportCreatePage {
|
||||
|
||||
private scrollToFirstError(): void {
|
||||
let firstId = ''
|
||||
if (this.genderErr !== '') {
|
||||
if (this.photoErr !== '') {
|
||||
firstId = 'field_photo'
|
||||
} else if (this.genderErr !== '') {
|
||||
firstId = 'field_gender'
|
||||
} else if (this.heightErr !== '' || this.weightErr !== '') {
|
||||
firstId = 'field_height'
|
||||
@@ -289,8 +428,21 @@ struct ReportCreatePage {
|
||||
'psych_style': this.psychStyle
|
||||
}
|
||||
const qObj: Object = questionnaire
|
||||
const photoInput = this.photoKeyInput.trim()
|
||||
const photoKey = photoInput !== '' ? photoInput : buildMockPhotoKey(AuthStore.getUserId() ?? 'anonymous')
|
||||
// 先上传照片拿真实 object_key(本机后端磁盘直传),成功后再建报告;
|
||||
// 上传失败不扣报告额度(服务端存在性校验也挡在配额预扣之前)。
|
||||
let photoKey: string = ''
|
||||
try {
|
||||
const jpegBuf = await ImageCodec.toJpegBuffer(this.photoUri)
|
||||
photoKey = await UploadApi.uploadReportPhoto(jpegBuf)
|
||||
} catch (e) {
|
||||
this.submitting = false
|
||||
if (e instanceof ApiError) {
|
||||
this.submitError = e.hint !== undefined ? `${e.message}(${e.hint})` : e.message
|
||||
} else {
|
||||
this.submitError = '照片处理失败,请重试'
|
||||
}
|
||||
return
|
||||
}
|
||||
const req: Record<string, Object> = {
|
||||
'questionnaire': qObj,
|
||||
'photo_object_key': photoKey,
|
||||
@@ -310,12 +462,14 @@ struct ReportCreatePage {
|
||||
return
|
||||
}
|
||||
this.submitting = false
|
||||
Tracker.track('report.create_submit')
|
||||
this.phase = 'polling'
|
||||
} catch (e) {
|
||||
this.submitting = false
|
||||
if (e instanceof ApiError) {
|
||||
if (e.code === 40920) {
|
||||
// 本期报告已生成:始终给「查看我的报告」入口(无本地记录也能从服务端找回)
|
||||
// 兜底分支:后端已不再抛 40920(重复生成由 report_basic 配额兜底),
|
||||
// 仅兼容旧端;始终给「查看我的报告」入口(无本地记录也能从服务端找回)
|
||||
this.showDupDialog = true
|
||||
return
|
||||
}
|
||||
@@ -425,7 +579,7 @@ struct ReportCreatePage {
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text('填写 6 项基础信息,约 1~3 分钟生成 32 维报告')
|
||||
Text('上传 1 张正面全身照 + 填写 6 项基础信息,约 1~3 分钟生成 32 维报告')
|
||||
.fontSize(12)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
@@ -433,6 +587,65 @@ struct ReportCreatePage {
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
|
||||
Column({ space: 14 }) {
|
||||
Text('个人照片')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#333333')
|
||||
Column({ space: 8 }) {
|
||||
this.FieldLabel({ label: '正面全身照', err: this.photoErr, required: true })
|
||||
if (this.photoUri === '') {
|
||||
Column({ space: 6 }) {
|
||||
Text('+')
|
||||
.fontSize(28)
|
||||
.fontColor('#999999')
|
||||
Text('上传个人照片')
|
||||
.fontSize(13)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
.width('100%')
|
||||
.aspectRatio(0.75) // 竖版 3:4:与全身照取景一致,不再用横条占位
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.backgroundColor('#F5F6F8')
|
||||
.borderRadius(10)
|
||||
.border({ width: 1, color: '#DDDDDD', style: BorderStyle.Dashed })
|
||||
.onClick(() => {
|
||||
this.onPhotoTap()
|
||||
})
|
||||
} else {
|
||||
Stack() {
|
||||
Image(this.photoUri)
|
||||
.width('100%')
|
||||
.aspectRatio(0.75) // 竖版 3:4:竖屏全身照不再被上下截断
|
||||
.objectFit(ImageFit.Cover)
|
||||
.borderRadius(10)
|
||||
Text('重选')
|
||||
.fontSize(12)
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
|
||||
.backgroundColor('#80000000')
|
||||
.borderRadius(12)
|
||||
.margin(8)
|
||||
.onClick(() => {
|
||||
this.onPhotoTap()
|
||||
})
|
||||
}
|
||||
.alignContent(Alignment.BottomEnd)
|
||||
}
|
||||
Text('请用本人正面全身照:正面站立、双手自然下垂、纯色背景、自然光,头到脚完整入镜')
|
||||
.fontSize(11)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.id('field_photo')
|
||||
|
||||
Column({ space: 14 }) {
|
||||
Text('基础信息')
|
||||
.fontSize(15)
|
||||
@@ -451,6 +664,7 @@ struct ReportCreatePage {
|
||||
.onClick(() => {
|
||||
this.gender = g
|
||||
this.genderErr = ''
|
||||
this.saveDraft()
|
||||
})
|
||||
}, (g: string) => g)
|
||||
}
|
||||
@@ -535,6 +749,7 @@ struct ReportCreatePage {
|
||||
})
|
||||
.onDateChange((d: Date) => {
|
||||
this.birthday = d
|
||||
this.saveDraft()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -587,6 +802,7 @@ struct ReportCreatePage {
|
||||
.onClick(() => {
|
||||
this.psychStyle = s
|
||||
this.styleErr = ''
|
||||
this.saveDraft()
|
||||
})
|
||||
}, (s: string) => s)
|
||||
}
|
||||
@@ -601,23 +817,6 @@ struct ReportCreatePage {
|
||||
.borderRadius(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
|
||||
// 照片区(#6):上传能力未开放,只做预告;对象键输入框对用户隐藏
|
||||
// (后端本就自动生成 report_photo/{userId}/{随机}.jpg,输入无用户价值)
|
||||
Column({ space: 8 }) {
|
||||
Text('照片(选填)')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#333333')
|
||||
Text('上传照片可以让报告更准,即将开放')
|
||||
.fontSize(11)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
if (this.formSummary !== '') {
|
||||
Text(this.formSummary)
|
||||
.fontSize(13)
|
||||
@@ -749,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 }) {
|
||||
@@ -788,7 +987,7 @@ struct ReportCreatePage {
|
||||
.backgroundColor('#FFFFFF')
|
||||
}
|
||||
|
||||
/** 提交前配额确认框(#5:终身 1 次权益禁止静默扣减)。 */
|
||||
/** 提交前配额确认框(#5:消耗额度禁止静默扣减)。 */
|
||||
@Builder
|
||||
QuotaConfirmDialog() {
|
||||
Column({ space: 14 }) {
|
||||
@@ -796,7 +995,7 @@ struct ReportCreatePage {
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text('首份形象报告为一次性权益,本次生成将使用 1 次。')
|
||||
Text('生成报告将消耗 1 次报告额度。')
|
||||
.fontSize(13)
|
||||
.fontColor('#666666')
|
||||
.textAlign(TextAlign.Center)
|
||||
@@ -840,6 +1039,50 @@ struct ReportCreatePage {
|
||||
.backgroundColor('#FFFFFF')
|
||||
}
|
||||
|
||||
/** 照片来源选择(相册/拍照):授权通过后弹出。 */
|
||||
@Builder
|
||||
PhotoSourceDialog() {
|
||||
Column({ space: 14 }) {
|
||||
Text('上传个人照片')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
Text('请上传本人正面全身照,照片仅用于本次形象诊断')
|
||||
.fontSize(13)
|
||||
.fontColor('#666666')
|
||||
.textAlign(TextAlign.Center)
|
||||
Button('相册选择')
|
||||
.width('100%')
|
||||
.height(42)
|
||||
.fontSize(15)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.doPickPhoto(false)
|
||||
})
|
||||
Button('拍照')
|
||||
.width('100%')
|
||||
.height(42)
|
||||
.fontSize(15)
|
||||
.backgroundColor(COLOR_PRIMARY)
|
||||
.onClick(() => {
|
||||
this.doPickPhoto(true)
|
||||
})
|
||||
Button('取消')
|
||||
.width('100%')
|
||||
.height(42)
|
||||
.fontSize(14)
|
||||
.backgroundColor('#F3F4F6')
|
||||
.fontColor('#666666')
|
||||
.onClick(() => {
|
||||
this.showPhotoSource = false
|
||||
})
|
||||
}
|
||||
.width('82%')
|
||||
.padding(20)
|
||||
.borderRadius(16)
|
||||
.backgroundColor('#FFFFFF')
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
Column() {
|
||||
@@ -855,6 +1098,28 @@ struct ReportCreatePage {
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
if (this.showFaceConsent) {
|
||||
FacePhotoConsent({
|
||||
usageText: '用途:照片仅用于为你生成形象诊断报告,不用于识别你的身份,也不做其他用途的分析。',
|
||||
onAgree: (): void => {
|
||||
this.onConsentAgree()
|
||||
},
|
||||
onRefuse: (): void => {
|
||||
this.onConsentRefuse()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (this.showPhotoSource) {
|
||||
Column() {
|
||||
this.PhotoSourceDialog()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#66000000')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
|
||||
if (this.showDupDialog) {
|
||||
Column() {
|
||||
this.DupDialog()
|
||||
|
||||
@@ -20,11 +20,27 @@ import { common } from '@kit.AbilityKit'
|
||||
import { picker, fileUri } from '@kit.CoreFileKit'
|
||||
import fs from '@ohos.file.fs'
|
||||
import { ContextHolder } from '../core/ContextHolder'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
import { TaskBonusState, PERIOD_DAY } from '../core/TaskBonusState'
|
||||
import { Tracker } from '../core/Tracker'
|
||||
import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme'
|
||||
|
||||
const PAGE_BG: string = '#F5F6F8'
|
||||
const TEXT_PRIMARY: string = '#222222'
|
||||
|
||||
/** POST /quota/tasks/share_report/claim 响应(share_report 任务闭环,T2b)。 */
|
||||
interface ClaimWire {
|
||||
task_key: string
|
||||
quota_key: string
|
||||
amount: number
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
}
|
||||
|
||||
/** 本周期已领(与后端 errors.py 对齐)。 */
|
||||
const CODE_ALREADY_CLAIMED: number = 40910
|
||||
|
||||
interface PdfRouteParams {
|
||||
pdfPath?: string
|
||||
title?: string
|
||||
@@ -100,13 +116,35 @@ struct ReportPdfPage {
|
||||
previewMode: systemShare.SharePreviewMode.DEFAULT
|
||||
}
|
||||
await controller.show(ContextHolder.get() as common.UIAbilityContext, opts)
|
||||
// 面板正常收起(含用户主动取消):不打扰,不提示
|
||||
// 面板正常收起(含用户主动取消):不打扰,不提示。
|
||||
// share_report 任务闭环(T2b):调起成功即视为完成(端侧自报,内测水位,
|
||||
// 产品方案 §3.2 决策理由 4);埋点 + 静默 claim 互不阻断。
|
||||
Tracker.track('report.share_invoke')
|
||||
this.claimShareBonus()
|
||||
} catch (e) {
|
||||
console.error(`PDF share failed: ${(e as Error).message}`)
|
||||
this.setActionText('分享失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* share_report 任务真实闭环:奖励到账提示;已领(40910)静默但同步已领态,
|
||||
* 其他失败静默——分享动作本身已完成,不拿奖励失败打扰用户。
|
||||
*/
|
||||
private async claimShareBonus(): Promise<void> {
|
||||
try {
|
||||
const req: Record<string, Object> = {}
|
||||
const d = await ApiClient.post<ClaimWire>('/api/v1/quota/tasks/share_report/claim', req)
|
||||
await TaskBonusState.markClaimed('share_report', PERIOD_DAY)
|
||||
this.setActionText(`感谢分享,额度 +${d.amount}`)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.code === CODE_ALREADY_CLAIMED) {
|
||||
// 今日已领:静默不打扰,但同步已领态(任务面板显示一致)
|
||||
await TaskBonusState.markClaimed('share_report', PERIOD_DAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async saveAs(): Promise<void> {
|
||||
try {
|
||||
const docPicker = new picker.DocumentViewPicker(ContextHolder.get())
|
||||
|
||||
@@ -16,13 +16,26 @@
|
||||
* (服务端幂等重放不过滤状态,同 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 } from '@kit.ArkUI'
|
||||
import { router, promptAction } from '@kit.ArkUI'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError, isAuthError } from '../core/ApiTypes'
|
||||
import { AuthStore } from '../core/AuthStore'
|
||||
import { ContextHolder } from '../core/ContextHolder'
|
||||
import { TaskBonusState, PERIOD_DAY, PERIOD_LIFETIME } from '../core/TaskBonusState'
|
||||
import { Tracker } from '../core/Tracker'
|
||||
import { COLOR_PRIMARY, COLOR_TEXT_SECONDARY } from '../core/Theme'
|
||||
import request from '@ohos.request'
|
||||
import fs from '@ohos.file.fs'
|
||||
@@ -33,8 +46,14 @@ import { LoadingView } from '../components/f2/LoadingView'
|
||||
import {
|
||||
CopySectionWire,
|
||||
DimensionWire,
|
||||
FullOutfitWire,
|
||||
FullResultWire,
|
||||
FullSceneWire,
|
||||
PaletteColorWire,
|
||||
PlanWeekWire,
|
||||
ReportCreateResp,
|
||||
ReportDetailWire,
|
||||
ReportListItemWire,
|
||||
ReportRouteParams,
|
||||
formatIsoTime,
|
||||
randomHex,
|
||||
@@ -50,11 +69,46 @@ interface QuotaWireResp {
|
||||
quota_key: string
|
||||
used: number
|
||||
limit_value: number
|
||||
bonus_granted: boolean
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
reset_at: string
|
||||
}
|
||||
|
||||
/** GET /reports/{id}/feedback 数组元素(T-P3-21)。 */
|
||||
interface FeedbackWireItem {
|
||||
dimension_key: string
|
||||
verdict: string
|
||||
correction: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** POST /reports/{id}/feedback 响应。updated=true 表示同维度覆盖更新(改判)。 */
|
||||
interface FeedbackUpsertResp {
|
||||
dimension_key: string
|
||||
verdict: string
|
||||
updated: boolean
|
||||
}
|
||||
|
||||
/** POST /quota/tasks/report_feedback/claim 响应(report_feedback 任务真实闭环)。 */
|
||||
interface ClaimWire {
|
||||
task_key: string
|
||||
quota_key: string
|
||||
amount: number
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
}
|
||||
|
||||
/** 本周期已领(与后端 errors.py 对齐)。 */
|
||||
const CODE_ALREADY_CLAIMED: number = 40910
|
||||
/** 状态冲突(与后端 errors.py 对齐):重新生成时输入缺失的早期种子报告走此码。 */
|
||||
const CODE_STATE_CONFLICT: number = 40900
|
||||
|
||||
/** PaletteGroup @Builder 的引用传递参数($$ 形态,数组重赋值才能刷新)。 */
|
||||
interface PaletteGroupParams {
|
||||
title: string
|
||||
colors: PaletteColorWire[]
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct ReportResultPage {
|
||||
@@ -76,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 = ''
|
||||
@@ -85,6 +140,26 @@ struct ReportResultPage {
|
||||
@State confirmRemaining: number = -1
|
||||
@State confirmResetAt: string = ''
|
||||
@State confirmLoaded: boolean = false
|
||||
// —— 结论反馈(T-P3-21):dimension_key → verdict 回显表 ——
|
||||
@State feedbackVerdicts: Record<string, string> = {}
|
||||
@State feedbackSubmitting: string = '' // 提交中的 dimension_key(防空点)
|
||||
// —— 深度诊断(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 {
|
||||
const raw = router.getParams()
|
||||
@@ -109,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()
|
||||
@@ -116,6 +192,7 @@ struct ReportResultPage {
|
||||
this.completedText = formatIsoTime(d.completed_at)
|
||||
const result = d.result
|
||||
this.degraded = result?.validation?.degraded === true
|
||||
Tracker.track('report.view', { 'degraded': this.degraded })
|
||||
this.summaryText = result?.copy?.summary ?? '(本报告暂无摘要)'
|
||||
this.sections = result?.copy?.sections ?? []
|
||||
const dims = result?.diagnosis?.dimensions ?? []
|
||||
@@ -126,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)
|
||||
@@ -135,6 +222,20 @@ 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()
|
||||
// 深度诊断入口卡:卡片渲染即曝光埋点,每进入结果页只打一次
|
||||
if (!this.cardViewTracked) {
|
||||
this.cardViewTracked = true
|
||||
Tracker.track('full_report.card_view')
|
||||
}
|
||||
this.loadFullReportList()
|
||||
}
|
||||
} catch (e) {
|
||||
this.loading = false
|
||||
this.loadErrorAuth = isAuthError(e)
|
||||
@@ -173,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 = '请求失败,请检查网络'
|
||||
@@ -272,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 === '')) {
|
||||
@@ -283,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
|
||||
@@ -291,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
|
||||
@@ -311,9 +423,185 @@ struct ReportResultPage {
|
||||
this.doRegenerate()
|
||||
} else if (action === 'export') {
|
||||
this.doExport()
|
||||
} else if (action === 'full') {
|
||||
this.doFullGenerate()
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 结论反馈(T-P3-21 + report_feedback 任务闭环) ====================
|
||||
|
||||
private toast(message: string): void {
|
||||
try {
|
||||
promptAction.showToast({ message: message, duration: 2000 })
|
||||
} catch (_e) {
|
||||
// toast 失败不阻断主流程
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉已有反馈回显(dimension_key → verdict);失败静默(不影响报告展示)。 */
|
||||
private async loadFeedback(): Promise<void> {
|
||||
try {
|
||||
const list = await ApiClient.get<FeedbackWireItem[]>(`/api/v1/reports/${this.reportId}/feedback`)
|
||||
const map: Record<string, string> = {}
|
||||
for (const item of list) {
|
||||
map[item.dimension_key] = item.verdict
|
||||
}
|
||||
this.feedbackVerdicts = map
|
||||
} catch (_e) {
|
||||
// 回显失败不打扰:卡片仍可按未反馈态提交
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 维度卡反馈提交:POST 成功才改本地已选态;随后静默 claim report_feedback
|
||||
* 任务奖励(成功 toast 到账、40910 静默、其他失败静默)。
|
||||
*/
|
||||
private async onFeedback(dimKey: string, verdict: string, correction: string): Promise<void> {
|
||||
if (this.feedbackSubmitting !== '') {
|
||||
return
|
||||
}
|
||||
this.feedbackSubmitting = dimKey
|
||||
try {
|
||||
const req: Record<string, Object> = { 'dimension_key': dimKey, 'verdict': verdict }
|
||||
if (correction !== '') {
|
||||
req['correction'] = correction
|
||||
}
|
||||
await ApiClient.post<FeedbackUpsertResp>(`/api/v1/reports/${this.reportId}/feedback`, req)
|
||||
const next: Record<string, string> = {}
|
||||
const keys: string[] = Object.keys(this.feedbackVerdicts)
|
||||
for (const k of keys) {
|
||||
next[k] = this.feedbackVerdicts[k]
|
||||
}
|
||||
next[dimKey] = verdict
|
||||
this.feedbackVerdicts = next
|
||||
Tracker.track('report.feedback_submit', { 'verdict': verdict, 'has_correction': correction !== '' })
|
||||
this.claimReportFeedback()
|
||||
} catch (e) {
|
||||
// 失败(网络/校验)toast 原因,不改卡片已选态
|
||||
this.toast(e instanceof ApiError ? e.message : '提交失败,请稍后重试')
|
||||
} finally {
|
||||
this.feedbackSubmitting = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** report_feedback 任务真实闭环:奖励到账 toast;已领(40910)静默但同步已领态。 */
|
||||
private async claimReportFeedback(): Promise<void> {
|
||||
try {
|
||||
const req: Record<string, Object> = {}
|
||||
const d = await ApiClient.post<ClaimWire>('/api/v1/quota/tasks/report_feedback/claim', req)
|
||||
await TaskBonusState.markClaimed('report_feedback', PERIOD_DAY)
|
||||
this.toast(`感谢反馈,额度 +${d.amount}`)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.code === CODE_ALREADY_CLAIMED) {
|
||||
// 今日已领:静默不打扰,但同步已领态(任务面板显示一致)
|
||||
await TaskBonusState.markClaimed('report_feedback', PERIOD_DAY)
|
||||
}
|
||||
// 其他失败静默:反馈本身已提交成功,不拿奖励失败打扰用户
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* first_report 任务真实闭环(T2b):报告成功展示即完成首次速测,静默 claim;
|
||||
* 已领(40910,含 lifetime 重复进页)静默同步已领态,其他失败静默——
|
||||
* 报告展示是主流程,奖励失败不打扰。
|
||||
*/
|
||||
private async claimFirstReport(): Promise<void> {
|
||||
try {
|
||||
const req: Record<string, Object> = {}
|
||||
const d = await ApiClient.post<ClaimWire>('/api/v1/quota/tasks/first_report/claim', req)
|
||||
await TaskBonusState.markClaimed('first_report', PERIOD_LIFETIME)
|
||||
this.toast(`形象速测完成,额度 +${d.amount}`)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.code === CODE_ALREADY_CLAIMED) {
|
||||
await TaskBonusState.markClaimed('first_report', PERIOD_LIFETIME)
|
||||
}
|
||||
// 其他失败静默
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 深度诊断(tier=full)入口卡 ====================
|
||||
|
||||
/**
|
||||
* 查报告列表(limit=20 对齐首页报告 Tab):找最新一份 tier=full 且
|
||||
* SUCCEEDED 的报告(已生成态 → 「查看深度诊断报告」),同时记下最新
|
||||
* basic 报告 id(full 报告页「查看基础版报告」的跳转目标)。
|
||||
* 失败静默:卡片按未生成态展示,生成按钮仍可用(服务端幂等兜底)。
|
||||
*/
|
||||
private async loadFullReportList(): Promise<void> {
|
||||
try {
|
||||
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.fullReportId = ''
|
||||
this.basicReportId = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成深度诊断:模式同 doRegenerate(新 report_id 必须采纳,轮询成功本页重拉)。 */
|
||||
private async doFullGenerate(): Promise<void> {
|
||||
if (this.fullGenerating || this.reportId === '') {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
const k = d.key
|
||||
if (k !== undefined && k !== '') {
|
||||
@@ -398,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) {
|
||||
@@ -455,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')
|
||||
@@ -611,8 +966,14 @@ struct ReportResultPage {
|
||||
.fontColor('#999999')
|
||||
}
|
||||
ForEach(this.analyzed, (d: DimensionWire) => {
|
||||
DimensionCard({ dim: d })
|
||||
}, (d: DimensionWire) => this.dimKey(d))
|
||||
DimensionCard({
|
||||
dim: d,
|
||||
feedbackVerdict: this.feedbackVerdicts[d.key ?? ''] ?? '',
|
||||
onFeedback: (dimKey: string, verdict: string, correction: string): void => {
|
||||
this.onFeedback(dimKey, verdict, correction)
|
||||
}
|
||||
})
|
||||
}, (d: DimensionWire) => `${this.dimKey(d)}_${this.feedbackVerdicts[d.key ?? ''] ?? ''}`)
|
||||
|
||||
this.DimGroupTitle('未覆盖', this.skipped.length, '灰显·附原因')
|
||||
if (this.skipped.length === 0) {
|
||||
@@ -628,6 +989,261 @@ struct ReportResultPage {
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
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(this.tier === 'full' ? '当前为深度诊断报告' : '形象深度诊断')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#333333')
|
||||
.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)
|
||||
}
|
||||
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)
|
||||
.onClick(() => {
|
||||
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')
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.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() {
|
||||
@@ -639,6 +1255,16 @@ struct ReportResultPage {
|
||||
}
|
||||
this.CopyCard()
|
||||
this.DiagnosisArea()
|
||||
// 深度诊断增量区块(仅 tier=full 报告有 result.full):色板 / 场景 / 30 天计划
|
||||
if (this.hasFull) {
|
||||
this.FullSeasonPaletteCard()
|
||||
this.FullScenesCard()
|
||||
this.FullPlanCard()
|
||||
}
|
||||
// 深度诊断入口卡:内容区底部,导出/重新生成按钮区之前
|
||||
if (this.statusRaw === 'SUCCEEDED') {
|
||||
this.FullReportCard()
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
@@ -660,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 报告')
|
||||
@@ -680,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 })
|
||||
@@ -696,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() {
|
||||
@@ -744,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",
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
"pages/LlmTestPage",
|
||||
"pages/TryonPage",
|
||||
"pages/HairDesignPage",
|
||||
"pages/ReportPdfPage"
|
||||
"pages/HairColorPage",
|
||||
"pages/ReportPdfPage",
|
||||
"pages/FittingRoomPage",
|
||||
"pages/DiagnosisLandingPage",
|
||||
"pages/DiagnosisFormPage"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"file": "连衣裙_00.jpg",
|
||||
"category": "连衣裙",
|
||||
"name": "蓝色碎花吊带裙"
|
||||
},
|
||||
{
|
||||
"file": "连衣裙_01.jpg",
|
||||
"category": "连衣裙",
|
||||
"name": "黑色长袖连衣裙"
|
||||
},
|
||||
{
|
||||
"file": "连衣裙_02.jpg",
|
||||
"category": "连衣裙",
|
||||
"name": "军绿衬衫裙"
|
||||
},
|
||||
{
|
||||
"file": "下装_03.jpg",
|
||||
"category": "下装",
|
||||
"name": "拼色直筒牛仔裤"
|
||||
},
|
||||
{
|
||||
"file": "上衣_04.jpg",
|
||||
"category": "上衣",
|
||||
"name": "红色荷叶边上衣"
|
||||
},
|
||||
{
|
||||
"file": "上衣_05.jpg",
|
||||
"category": "上衣",
|
||||
"name": "白底刺绣T恤"
|
||||
},
|
||||
{
|
||||
"file": "上衣_06.jpg",
|
||||
"category": "上衣",
|
||||
"name": "插肩印花T恤"
|
||||
},
|
||||
{
|
||||
"file": "下装_07.jpg",
|
||||
"category": "下装",
|
||||
"name": "粉色缎面半身裙"
|
||||
},
|
||||
{
|
||||
"file": "上衣_08.jpg",
|
||||
"category": "上衣",
|
||||
"name": "红色短袖T恤"
|
||||
},
|
||||
{
|
||||
"file": "下装_09.jpg",
|
||||
"category": "下装",
|
||||
"name": "米白阔腿裤"
|
||||
},
|
||||
{
|
||||
"file": "连衣裙_10.jpg",
|
||||
"category": "连衣裙",
|
||||
"name": "红色碎花连衣裙"
|
||||
},
|
||||
{
|
||||
"file": "下装_11.jpg",
|
||||
"category": "下装",
|
||||
"name": "卡其色长半身裙"
|
||||
},
|
||||
{
|
||||
"file": "下装_12.jpg",
|
||||
"category": "下装",
|
||||
"name": "宝蓝色阔腿裤"
|
||||
},
|
||||
{
|
||||
"file": "上衣_13.jpg",
|
||||
"category": "上衣",
|
||||
"name": "深灰针织Polo衫"
|
||||
},
|
||||
{
|
||||
"file": "连衣裙_14.jpg",
|
||||
"category": "连衣裙",
|
||||
"name": "白色碎花长裙"
|
||||
},
|
||||
{
|
||||
"file": "上衣_15.jpg",
|
||||
"category": "上衣",
|
||||
"name": "米白半拉链针织衫"
|
||||
},
|
||||
{
|
||||
"file": "外套_16.jpg",
|
||||
"category": "外套",
|
||||
"name": "白色羽绒服"
|
||||
},
|
||||
{
|
||||
"file": "外套_17.jpg",
|
||||
"category": "外套",
|
||||
"name": "米色牛仔夹克"
|
||||
},
|
||||
{
|
||||
"file": "外套_18.jpg",
|
||||
"category": "外套",
|
||||
"name": "白色牛仔外套"
|
||||
},
|
||||
{
|
||||
"file": "外套_19.jpg",
|
||||
"category": "外套",
|
||||
"name": "白色羽绒马甲"
|
||||
},
|
||||
{
|
||||
"file": "外套_20.jpg",
|
||||
"category": "外套",
|
||||
"name": "深蓝羽绒夹克"
|
||||
},
|
||||
{
|
||||
"file": "外套_21.jpg",
|
||||
"category": "外套",
|
||||
"name": "白色针织开衫"
|
||||
},
|
||||
{
|
||||
"file": "上衣_22.jpg",
|
||||
"category": "上衣",
|
||||
"name": "白色印花T恤"
|
||||
},
|
||||
{
|
||||
"file": "上衣_23.jpg",
|
||||
"category": "上衣",
|
||||
"name": "米色灯芯绒衬衫"
|
||||
},
|
||||
{
|
||||
"file": "上衣_24.jpg",
|
||||
"category": "上衣",
|
||||
"name": "墨绿圆领毛衣"
|
||||
},
|
||||
{
|
||||
"file": "上衣_25.jpg",
|
||||
"category": "上衣",
|
||||
"name": "黑色格纹领T恤"
|
||||
},
|
||||
{
|
||||
"file": "连衣裙_26.jpg",
|
||||
"category": "连衣裙",
|
||||
"name": "粉色碎花系带长裙"
|
||||
},
|
||||
{
|
||||
"file": "连衣裙_27.jpg",
|
||||
"category": "连衣裙",
|
||||
"name": "白色针织吊带长裙"
|
||||
},
|
||||
{
|
||||
"file": "套装_28.jpg",
|
||||
"category": "套装",
|
||||
"name": "背心阔腿裤套装"
|
||||
},
|
||||
{
|
||||
"file": "外套_29.jpg",
|
||||
"category": "外套",
|
||||
"name": "深色双排扣西装"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 101 KiB |
@@ -48,19 +48,19 @@
|
||||
| `T-P1-25b` 四档熔断 | ✅(阈值待重算) | 60/80/90/100% 四档降级 + critical 豁免 + Redis 手动 override + Prometheus 指标;**"被拒请求配额净扣减 = 0"有集成断言**;⚠️ `LLM_DAILY_BUDGET_CENTS=100000` 仍是旧价占位,8-17 后按实测重算 |
|
||||
| `T-P1-13` 双令牌会话 | ✅ | refresh 一次性轮换(FOR UPDATE 行锁)+ 服务端撤销 + 重放检测告警;dev-token 向后兼容 |
|
||||
| `T-P1-10` 埋点通道(服务端) | ✅ | `POST /events/batch` fail-close 整批校验 + PII 剔除;事件字典校验待 `TD-23` 定稿后收紧 |
|
||||
| `T-P1-27` 直传预签名 | ✅(联调待 COS) | 场景/类型/大小三重校验 + 可审计 key 布局;SDK 隔离在 Protocol 后,16 单测零网络;真实桶联调归《前置开通清单》§2 |
|
||||
| `T-P1-27` 直传预签名 | ✅(联调待 COS)→ **E2 变更后由本地存储替代** | 原三重校验 + 可审计 key 布局保留;现行路径 `POST /storage/upload` 后端中转 + 签名下载 + temp 2h 清理(celery beat),COS 链路降为回迁保留 |
|
||||
| `T-P1-12` 华为账号登录(代码) | ✅(联调待 AGC) | OIDC JWKS 验签(kid 轮换、alg 锁定、audience 空配置拒绝)+ UnionID 映射;自造密钥全测,19 单测 |
|
||||
| `T-P1-12b/12c` 短信登录(代码) | ✅(发送待签名审核) | 验证码哈希存储/一次性/5 次作废 + 三重频控(脚本化刷量 11 次断言拦截)+ 掩码纪律;fake/tencent 双通道可切 |
|
||||
| `T-P1-12b/12c` 邮箱登录(代码) | ✅(发送待 SMTP 授权码) | **2026-08-15 需求变更:短信→邮箱(E1)并已交付**——`email_login.py` 验证码哈希存储/一次性/5 次作废 + 三重频控(脚本化刷量 11 次断言拦截)+ 掩码纪律;fake/smtp 双通道可切(QQ/163 授权码),旧短信通道代码已移除 |
|
||||
|
||||
### 1.2 本 Sprint 待补(BE)
|
||||
|
||||
| 任务 | 内容 | 验收标准 |
|
||||
| --- | --- | --- |
|
||||
| `T-P1-04` 🔴 | 腾讯云基础设施开通(VPC/CVM/PG/Redis/COS 四桶/CDN/CLS) | 四桶权限与生命周期策略生效;**临时桶 2 小时清理实测**(`CR-02`) |
|
||||
| `T-P1-04` 🔴 | ~~腾讯云基础设施开通~~ **已按《前置开通清单》v2 降级路线收缩**:腾讯云全家桶暂缓,仅剩华为 AGC(§1)+ 发信邮箱授权码(§2) | 华为调试证书可拉起授权页;测试邮箱能收到验证码 |
|
||||
| `T-P1-05` | 密钥改由 SSM/KMS 注入 | 镜像与代码中无任何密钥;`APP_ENV=prod` 时占位密钥直接拒绝启动(已实现,需接 SSM) |
|
||||
| `T-P1-27` | 图片直传预签名 URL + 临时桶生命周期 | 图片不经应用服务器;2 小时自动清理 |
|
||||
| `T-P1-27` | ~~图片直传预签名~~ **已按 E2 变更为本地磁盘并交付**:`POST /storage/upload` 后端中转 + HMAC 签名下载 URL + temp 目录 2 小时清理(celery beat);COS 预签名链路保留为回迁路径 | 上传下载闭环;2 小时自动清理可观察 |
|
||||
| `T-P1-11`/`12`/`13` | 用户模型、华为账号登录、双令牌会话 | UnionID 映射唯一;登出后旧令牌立即失效 |
|
||||
| `T-P1-12b`/`12c` 🔴 | 短信登录 + 三重频控防刷 | **断开华为依赖后本通道仍可登录**;脚本化刷量测试证明拦截有效 |
|
||||
| `T-P1-12b`/`12c` 🔴 | ~~短信登录~~ 邮箱登录(E1 变更,代码已交付)+ 三重频控防刷 | **断开华为依赖后本通道仍可登录**;脚本化刷量测试证明拦截有效;待 SMTP 授权码落位后真实发信验证 |
|
||||
| `T-P1-25b` | 日成本熔断补齐四档降级与后台开关 | 注入成本可复现四档;**被拒请求配额净扣减 = 0**;在途任务放行 |
|
||||
| `T-P1-09`/`10` | 三张看板 + 埋点事件字典 | 能回答架构 §15 的三个问题 |
|
||||
|
||||
@@ -249,9 +249,13 @@
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.1
|
||||
**文档版本**:v1.2
|
||||
**创建日期**:2026-08-13
|
||||
**最后更新**:2026-08-14
|
||||
**最后更新**:2026-08-16
|
||||
**状态**:待评审
|
||||
**2026-08-16 变更**(对应《前置开通清单》v2 个人开发者降级路线):`T-P1-12b/12c`
|
||||
短信登录→邮箱验证码登录(E1,代码已交付,待 SMTP 授权码);`T-P1-27` COS 直传→
|
||||
本地磁盘存储(E2,已交付:multipart 上传 + HMAC 签名下载 + temp 2h 清理挂 celery beat);
|
||||
`T-P1-04` 腾讯云全家桶收缩为华为 AGC + 发信邮箱两件事。
|
||||
**关联文档**:《开发任务拆解》(任务定义与依赖)、《需求规格说明》(验收指标唯一事实源)、
|
||||
《技术架构文档》(token 预算与成本模型)、《技术选型与调研报告》(`TD-00`~`TD-33`)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# T2b · 任务换额度:端侧闭环与完成判定
|
||||
|
||||
> 对应任务:`T-P1-26` 收尾件之二(T2 的后续迭代)| 需求:`FR-BILL-03`、`FR-BILL-04`、`FR-BILL-07` | 跨端任务:后端(meida_backend)+ 端侧(app/)
|
||||
> 先读《README.md(协作规程)》与《README-FE批次.md》,遵守全部纪律。
|
||||
> 产品定稿依据:产品方案 §3.2「任务奖励额度的获取方式」(2026-09-11 定稿表与决策理由)。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
把「做任务换额度」从「只有签到能真领」补齐为完整闭环:
|
||||
|
||||
1. 任务目录按产品定稿落地(4 项上线 + `invite_friend` 下架),奖励数值由占位值 1 改为定稿值;
|
||||
2. 有服务端可信信号的任务(`report_feedback` / `first_report`)在 claim 时做完成校验,无凭据拒绝;
|
||||
3. 端侧面板从「toast 占位」升级为真实「去完成」跳转 + 完成点自动 claim;
|
||||
4. 全程遵守 `FR-BILL-07`:文案不出现价格/购买/会员/升级字样。
|
||||
|
||||
## 2. 现状事实(2026-09-11 读码核实)
|
||||
|
||||
### 后端(meida_backend)
|
||||
|
||||
- ✅ `POST /quota/tasks/{task_key}/claim` 已实现(T2):幂等(`bl_ledger.idempotency_key` 快捷路径 + 唯一约束并发兜底)、封顶(`grant_bonus` 对 `task_bonus_max`)、配置化(`TASK_BONUS_RULES_JSON`)、错误码 40410/40910/40310 文案合规。见 `apps/api/routers/quota.py:65`、`packages/billing/service.py:306`、`packages/domain/billing/quota.py:146`。
|
||||
- ✅ 配额种子与产品方案 §3.2 对齐(`migrations/versions/0003_align_quota_with_product_ssot.py`):`agent_chat` 10/10/40、`tryon` 3/3/8、`hairstyle` 3/2/6、`hd_export` 1/0/3、`report_basic` 终身 1、`report_regen` 月 1/1/2。
|
||||
- ❌ **claim 不做任何完成凭据校验**:任何登录用户对任何 task_key 直接 POST 即领。`report_feedback`(`rp_feedback` 表)与 `first_report`(`rp_report` 表)明明有可信信号却没用上。
|
||||
- ❌ `task_bonus_rules` 默认值(`packages/shared/config.py:136`)amount 全是占位值 1,且映射与 §3.2 总量对不上:换装最多 +2/日(表定 +3)、发型 0(表定 +2)、对话 +2(表定 +10)。
|
||||
- ❌ 无任务目录下发接口——V1 决定端侧静态表,不做。
|
||||
- 事实备注:`rp_share` 表已建结构但无路由(models.py 注释「V1 只建结构」);`POST /events/batch` 无事件白名单(事件字典待 TD-23)。
|
||||
|
||||
### 端侧(app/entry/src/main/ets/)
|
||||
|
||||
- ✅ `components/f1/TaskBonusPanel.ets`:五项静态任务、已领态走 `core/TaskBonusState.ets`(preferences `meida_task_bonus`,跨重启)、40910 同步已领态、文案合规;已挂载 5 处(`ProfileContent` / `ChatPage` / `FittingRoomPage` / `HairDesignPage` / `HairColorPage`)。
|
||||
- ✅ 真实闭环已有两条:`sign_in` 点击即 claim(面板内);`report_feedback` 在报告页反馈提交成功后静默自动 claim(`ReportResultPage.ets:435`)。
|
||||
- ✅ `DailyLimiter.setBonus` 已把 `bonus_granted` 计入试穿/试戴当日上限;`ChatPage` 对话额度服务端口径(remaining 含 bonus)。
|
||||
- ❌ 其余 3 项任务是 toast 占位(「即将上线」),无「去完成」跳转。
|
||||
- ❌ `share_report` 无联动:`ReportPdfPage.ets:90` 已有 Share Kit `systemShare` 分享 PDF,但分享动作与 claim 之间没有凭据、没有联动。
|
||||
- ❌ 报告生成成功处无 `first_report` claim 联动。
|
||||
- ❌ 面板不展示每项的奖励数值与周期(任务表纪律是"数值不硬编码",定稿后可写入展示文案,到账仍以 claim 响应为准)。
|
||||
- 🐞 `ChatPage.ets:36` `QuotaWireResp.bonus_granted` 类型误写为 `boolean`(后端返回 number;该字段暂未消费,本迭代顺手修正)。
|
||||
|
||||
### 判定与埋点
|
||||
|
||||
- ✅ 埋点链路通:`Tracker` → `POST /events/batch` → `ob_event`(整批 fail-close 校验)。
|
||||
- ❌ 任务完成事件目前只有 `task_bonus.claim_result`;无面板曝光、任务点击、分享调起事件。
|
||||
- 产品决策(已写进 §3.2):**埋点只作观测,不作发放凭据**——埋点本身可伪造且有 20 条/30s 节流延迟,拿它做校验是自欺。
|
||||
|
||||
## 3. V1 任务目录(产品定稿,2026-09-11)
|
||||
|
||||
| task_key | 名称 | quota_key | amount | claim_period | 完成判定 | 状态 |
|
||||
|----------|------|-----------|--------|--------------|---------|------|
|
||||
| `sign_in` | 每日签到 | `agent_chat` | **10** | `day` | 点击签到即完成,无额外校验 | 上 |
|
||||
| `report_feedback` | 给报告结论打反馈 | `hairstyle` | **2** | `day` | 服务端校验当日 `rp_feedback` 存在 | 上 |
|
||||
| `share_report` | 分享我的报告 | `tryon` | **3** | `day` | 端侧自报,无服务端校验(内测水位) | 上 |
|
||||
| `first_report` | 完成首次形象速测 | `report_regen` | **1** | `lifetime` | 服务端校验 `rp_report` 存在已完成报告 | 上 |
|
||||
| `invite_friend` | 邀请好友 | — | — | — | 无可信信号 | **下架**(env 不下发该键) |
|
||||
|
||||
变更点:删除 `profile_complete`(资料维护 `FR-ACC-06` V1 未实现,无可信信号),新增 `first_report`;`report_feedback` 由 `report_regen` 改挂 `hairstyle`(理由见产品方案 §3.2 决策理由第 3 条)。
|
||||
|
||||
## 4. 实现要点
|
||||
|
||||
### 后端(worktree 纪律见《README.md》)
|
||||
|
||||
允许改动:`packages/billing/service.py`、`packages/billing/repo.py`(如需)、`packages/billing/schemas.py`(如需)、测试新文件。
|
||||
|
||||
1. **B1 · 定稿数值走 env**:部署/本地 `.env` 的 `TASK_BONUS_RULES_JSON` 改为:
|
||||
```json
|
||||
{
|
||||
"sign_in": {"quota_key": "agent_chat", "amount": 10, "claim_period": "day"},
|
||||
"report_feedback": {"quota_key": "hairstyle", "amount": 2, "claim_period": "day"},
|
||||
"share_report": {"quota_key": "tryon", "amount": 3, "claim_period": "day"},
|
||||
"first_report": {"quota_key": "report_regen", "amount": 1, "claim_period": "lifetime"}
|
||||
}
|
||||
```
|
||||
不含 `invite_friend`——不下发即下架(claim 返回 40410「任务不存在或暂未开放」),这是配置化设计的红利,不要为它写分支代码。
|
||||
⚠️ `config.py` 的默认值是占位且**禁改**;在提交信息里写明「默认值与定稿不一致,建议协调提交同步默认值与 `.env.example`」,由协调者统一处理。
|
||||
2. **B2 · claim 完成校验钩子**:`BillingService.claim_task_bonus` 在解析规则后、幂等快捷路径**之前**,按 task_key 执行完成校验(映射写死在 service 内即可,不做配置化):
|
||||
- `report_feedback`:`rp_feedback` 存在 `user_id` 匹配且 `created_at` 落在当日(UTC+8,与 `period_key` 同时区口径)的行;
|
||||
- `first_report`:`rp_report` 存在 `user_id` 匹配且状态为已完成的行;
|
||||
- `sign_in` / `share_report`:**不校验**,写注释说明产品决策(签到行为本身即任务;分享内测期自报,`hard_cap` 兜底,见 §3.2 决策理由 4)。
|
||||
- 校验不过 → `AppError(ErrorCode.TASK_BONUS_NOT_ELIGIBLE)`。**该错误码尚不存在**(建议 40311,meta「先完成任务,再来领奖励」)——`errors.py` 禁改,在提交信息中列出需求,由协调提交统一加;本地开发可先在 service 里 `raise AppError(...)` 引用占位并在提交信息标注。
|
||||
3. **B3 · 幂等与封顶语义不变**:40910/40310 路径、`delta=0` 流水口径不动;校验逻辑不得影响既有测试。
|
||||
|
||||
### 端侧(纯 Windows,构建门禁见《README-FE批次.md》§1)
|
||||
|
||||
允许改动:`components/f1/TaskBonusPanel.ets`、`pages/ReportResultPage.ets`、`pages/ReportPdfPage.ets`、`pages/ChatPage.ets`(仅 B 端 wire 类型修正)。其余页面如需挂回调点,在提交信息里说明。
|
||||
|
||||
1. **E1 · 面板任务表更新为定稿 4 项**:删 `invite_friend`;`profile_complete` 替换为 `first_report`(名称「完成首次形象速测」);每项 desc 写清奖励内容(如「领 10 轮 AI 对话额度」「领 2 次发型试戴额度」)并标注周期(「每日」/「一次性」)。到账数值仍以 claim 响应 `amount` 为准,desc 只是展示。
|
||||
2. **E2 · 「去完成」真实跳转**(`router.pushUrl`,禁止 import 页面):
|
||||
- `first_report` → `pages/DiagnosisLandingPage`;进入面板时若已可领(服务端已有报告且未领过)则按钮直接为「领额度」走 claim;
|
||||
- `report_feedback` → 报告结果页(带 `reportId` 参数);无报告时 toast「先生成你的形象速测,再来反馈」;
|
||||
- `share_report` → `pages/ReportPdfPage`(分享入口所在页);无报告时同上引导;
|
||||
- `sign_in` 保持点击即领。
|
||||
3. **E3 · 完成点自动 claim**(与既有 `claimReportFeedback` 同款静默闭环:成功 toast 到账、40910 静默同步已领态、其他失败静默):
|
||||
- `ReportPdfPage.share()` 调起系统分享面板成功后:静默 claim `share_report` + `TaskBonusState.markClaimed('share_report', PERIOD_DAY)` + `Tracker.track('report.share_invoke', ...)`;
|
||||
- 报告生成任务成功(`ReportResultPage` 展示 SUCCEEDED 结果的入口路径):静默 claim `first_report` + `markClaimed`。
|
||||
4. **E4 · 状态与文案**:见 §5,逐字遵守。
|
||||
5. **E5 · 埋点补齐**(事件名字面量、props 只放低维枚举/数值):`task_bonus.panel_view`、`task_bonus.task_tap`(props: `task_key`)、`report.share_invoke`、`task_bonus.claim_result`(已有,保留)。
|
||||
6. **E6 · 修正** `ChatPage.ets:36` `bonus_granted: boolean` → `number`。
|
||||
|
||||
## 5. UX 与文案规格(FR-BILL-07,逐字遵守)
|
||||
|
||||
**面板信息架构**:标题「做任务换额度」;分组一「每日任务」(签到 / 打反馈 / 分享报告);分组二「新手任务」(完成首次形象速测);底部说明保留「奖励以实际到账为准,额度每日重置」。
|
||||
|
||||
**按钮五态**:
|
||||
|
||||
| 状态 | 文案 |
|
||||
|------|------|
|
||||
| 可领取(签到 / 已满足条件) | 「领额度」 |
|
||||
| 需跳转完成 | 「去完成」 |
|
||||
| 领取中 | 「领取中…」 |
|
||||
| 已领(日周期) | 「今日已领」 |
|
||||
| 已领(终身) | 「已领取」 |
|
||||
|
||||
**toast**:
|
||||
|
||||
| 场景 | 文案 |
|
||||
|------|------|
|
||||
| 领取成功 | 「签到成功,额度 +{amount}」/「感谢反馈,额度 +{amount}」(amount 取响应值,不硬编码) |
|
||||
| 40910 本周期已领 | 「今天已经领过啦」 |
|
||||
| 40310 奖励封顶 | 「今日任务奖励已达上限,明天再来」 |
|
||||
| 40311 未完成 | 「先完成任务,再来领奖励」 |
|
||||
| 无报告时去做反馈/分享 | 「先生成你的形象速测,再来反馈」 |
|
||||
|
||||
**红线**:全部新增文案不得出现价格、购买、会员、升级、充值等字样(`FR-BILL-07`);额度耗尽入口文案维持「今日额度已用完,明天再来,或做个任务多换几次」口径。
|
||||
|
||||
## 6. 测试要求
|
||||
|
||||
### 后端
|
||||
|
||||
- 单测:校验钩子纯逻辑(若抽纯函数)覆盖有/无凭据两分支。
|
||||
- 集成测试新文件 `tests/integration/test_task_bonus_eligibility.py`(允许跑,`MEIDA_RUN_INTEGRATION=1`):
|
||||
1. 无报告用户 claim `first_report` → 40311;创建报告完成后 → 200 且 `bonus_granted`=1;
|
||||
2. 无反馈 claim `report_feedback` → 40311;提交一条反馈后 → 200 且发型 `bonus_granted`=2;
|
||||
3. `sign_in` claim → 200,`agent_chat` `bonus_granted`=10;当日再 claim → 40910;
|
||||
4. `invite_friend`(不在 env)→ 40410;
|
||||
5. 40311 与 40310 路径均**不写流水**(查 `bl_ledger` 无新行)。
|
||||
- 提交前全绿:`ruff check` / `ruff format --check` / `mypy`(范围同 T2)/ `lint-imports` / `pytest -q`。
|
||||
|
||||
### 端侧
|
||||
|
||||
- 构建门禁(唯一验收命令,见《README-FE批次.md》§1)`BUILD SUCCESSFUL`;
|
||||
- ArkTS 严格模式铁律(README-FE批次 §3)逐条遵守:显式泛型、具名 wire interface、throw 只抛 ApiError。
|
||||
|
||||
## 7. 禁改清单(通用禁改之外)
|
||||
|
||||
- 后端:`packages/shared/config.py`、`packages/shared/errors.py`、`packages/shared/models.py`、`migrations/**`、`apps/api/routers/events.py`、`packages/analytics/**`。新错误码与默认值同步**只写进提交信息**,由协调者处理。
|
||||
- 端侧:`core/**`(含 `TaskBonusState.ets`、`Tracker.ets`、`DailyLimiter.ets`——本迭代需求已够,不要顺手扩展)、`module.json5`、`main_pages.json`、resources、`oh-package*.json5`。
|
||||
- 不改配额种子数值、不动 `grant_bonus` 语义、不改既有测试断言语义。
|
||||
|
||||
## 8. 验收标准(按组,每条可验收)
|
||||
|
||||
### 后端
|
||||
|
||||
1. 四个 task_key 按 §3 定稿数值 claim 成功,响应 `amount` 与定稿一致;`invite_friend` 返回 40410。
|
||||
2. 无凭据 claim `report_feedback` / `first_report` 返回 40311 且无流水;有凭据后 claim 成功。
|
||||
3. 重复 claim 40910、封顶 40310 行为与 T2 验收一致,无回归(既有 `test_task_bonus.py` 全绿)。
|
||||
4. 后端检查全绿(§6)。
|
||||
|
||||
### 端侧
|
||||
|
||||
5. 面板只展示 4 项定稿任务,奖励文案、周期标注、五态按钮与 §5 一致。
|
||||
6. 三项「去完成」跳转可达目标页;无报告场景给引导 toast 而非报错。
|
||||
7. 分享调起成功、报告生成成功两处自动 claim 生效,面板已领态同步(含杀进程重启后)。
|
||||
8. `ChatPage` wire 类型修正后构建绿;埋点新增事件在 `ob_event` 可见。
|
||||
|
||||
### 联调
|
||||
|
||||
9. 全链路走查(真机或模拟器连本地后端):签到 → 对话额度卡 +10;报告页打反馈 → 发型当日上限 +2(`DailyLimiter` 生效);PDF 页分享 → 换装当日上限 +3;完成首次速测 → 重生成额度 +1(个人中心额度卡可见)。
|
||||
10. 异常路径走查:同日重复领取 →「今天已经领过啦」;未打反馈直接在面板点领取(若 UI 可达)→「先完成任务,再来领奖励」。
|
||||
11. 文案红线检查:任务面板、toast、额度耗尽提示全文检索无「价格/购买/会员/升级/充值/¥」字样。
|
||||
|
||||
## 9. 提交信息
|
||||
|
||||
后端:
|
||||
|
||||
```
|
||||
feat(billing): task bonus final rules + eligibility check (T2b)
|
||||
|
||||
TASK_BONUS_RULES_JSON 定稿(签到+10对话/反馈+2发型/分享+3换装/首测+1重生成,
|
||||
invite_friend 下架);report_feedback/first_report claim 前完成校验(rp_feedback/
|
||||
rp_report 可信信号),sign_in/share_report 不校验(产品决策见产品方案 §3.2)。
|
||||
|
||||
偏差说明:TASK_BONUS_NOT_ELIGIBLE(40311) 错误码与 config.py 默认值同步需协调提交;
|
||||
<其余偏差写这里,无则写"无">
|
||||
```
|
||||
|
||||
端侧:
|
||||
|
||||
```
|
||||
feat(f1): task bonus panel final catalog + auto-claim hooks (T2b)
|
||||
|
||||
面板 4 项定稿任务与「去完成」真实跳转;分享调起/报告生成成功自动 claim;
|
||||
新增 panel_view/task_tap/share_invoke 埋点;修正 ChatPage bonus_granted 类型。
|
||||
|
||||
偏差说明:<如有写在这里,无则写"无">
|
||||
```
|
||||
@@ -151,7 +151,7 @@ M2 知识库 M3 报告闭环 M4-a 换装/发型 │
|
||||
| T-P1-01 | DEV | 仓库结构与包边界搭建 | 可运行骨架 | 目录结构符合架构 §16.1;`packages/domain` 无 IO 依赖 | NFR-MAINT-03 |
|
||||
| T-P1-02 | OPS | CI 流水线(ruff + mypy strict + pytest + 依赖规则检查) | CI 配置 | 违规提交被阻断 | NFR-MAINT-02/03 |
|
||||
| T-P1-03 | OPS | 本地开发环境(Compose:PG+pgvector、Redis、COS mock) | compose 文件 + 文档 | 新成员一条命令起环境 | — |
|
||||
| T-P1-04 | OPS | 腾讯云基础设施开通与配置(VPC/CVM/PG/Redis/COS 四桶/CDN/CLS) | 环境清单 | 四桶权限与生命周期策略生效 | TD-03、CR-02 |
|
||||
| T-P1-04 | OPS | ~~腾讯云基础设施开通~~ **降级路线(2026-08-15):腾讯云暂缓**,仅剩华为 AGC + 发信邮箱授权码(《前置开通清单》v2 §1/§2);PG/Redis 已在本地 WSL 运行 | 环境清单 | 华为调试证书可拉起授权页;测试邮箱能收到验证码 | TD-03、CR-02 |
|
||||
| T-P1-05 | DEV | 配置与密钥管理(SSM/KMS 接入) | 配置模块 | 无密钥进代码与镜像 | NFR-SEC-01 |
|
||||
| T-P1-06 | DEV | Alembic 迁移基线与规范 | 首个迁移 | 迁移可正向与回滚 | NFR-MAINT-06 |
|
||||
| T-P1-07 | DEV | 统一响应、错误码体系、异常处理中间件 | 中间件 + 错误码表 | 每个错误码有 message + hint | FR-SYS-02 |
|
||||
@@ -166,8 +166,8 @@ M2 知识库 M3 报告闭环 M4-a 换装/发型 │
|
||||
| T-P1-11 | DEV | 用户数据模型与身份映射 | 表 + 迁移 | UnionID 映射唯一;预留 `profile_id` | FR-ACC-01、FR-ACC-12 |
|
||||
| T-P1-12 | DEV | 华为账号登录(服务端) | 接口 | 登录成功率 ≥ 99%(联调环境) | FR-ACC-01 |
|
||||
| T-P1-13 | DEV | 双令牌会话与撤销 | 接口 | 登出后旧令牌立即失效 | FR-ACC-05 |
|
||||
| T-P1-12b | DEV | **短信验证码登录**(下发 + 校验 + 腾讯云 SMS 接入) | 接口 | 6 位码、5 分钟有效、一次性、失败 5 次作废;**华为侧异常时本通道仍可登录**(须有断开华为依赖的测试) | FR-ACC-04、**TD-32** |
|
||||
| T-P1-12c | DEV | **短信防刷**(号码/IP/设备三重频控 + 人机校验 + 发送量告警) | 中间件 + 告警 | **脚本化刷量测试证明拦截有效**;号码在日志与后台全部掩码 | FR-ACC-14、NFR-SEC-03/05 |
|
||||
| T-P1-12b | DEV | **邮箱验证码登录**(2026-08-15 变更:原短信,个人主体办不了签名;下发 + 校验 + 个人邮箱 SMTP 接入,**E1 已交付**) | 接口 | 6 位码、5 分钟有效、一次性、失败 5 次作废;**华为侧异常时本通道仍可登录**(须有断开华为依赖的测试) | FR-ACC-04、**TD-32** |
|
||||
| T-P1-12c | DEV | **邮箱防刷**(邮箱/IP/设备三重频控,参数沿短信原值;人机校验 + 发送量告警后置) | 中间件 + 告警 | **脚本化刷量测试证明拦截有效**(已交付);邮箱在日志与后台全部掩码 | FR-ACC-14、NFR-SEC-03/05 |
|
||||
| T-P1-14 | DEV | 游客模式与数据合并 | 接口 | 合并幂等;多设备场景有确定策略;**游客配额按设备维度终身计量**(非每日),用尽引导登录而非报错 | FR-ACC-02/03 |
|
||||
| T-P1-16b | DEV | **清空我的照片**(保留账号) | 接口 + 页面 | COS 对象与 DB 引用同步清除;签名 URL 立即失效;二次确认 + 回执 + 审计日志 | **FR-ACC-15**、CR-02 |
|
||||
| T-P1-15 | DEV | 同意授权记录(含人脸单独同意) | 表 + 接口 | 记录含版本与时间,可查询 | CR-01、FR-ACC-08 |
|
||||
@@ -191,7 +191,7 @@ M2 知识库 M3 报告闭环 M4-a 换装/发型 │
|
||||
| **T-P1-25c** | DEV | **峰谷单价按调用时刻计价** | 两套价目表 + 时段判定 | 高峰(北京时间 09:00-12:00、14:00-18:00)与空闲各一套单价;成本在**调用当时**算好落库,事后不按当前价重算;跨时段有测试 | `NFR-COST-01`、TD-04 |
|
||||
| T-P1-26 | DEV | **配额账本**(余额、流水、预扣三段式、周期重置、规则配置化) | 模块 + 接口 | 余额可由流水重算;失败自动退还;**表结构含 `tx_type` 字段,V2 上支付无需迁移**;服务端硬校验不可绕过;**`bl_quota_rule` 种子必须与产品方案 §3.2 一致**(含 `hairstyle` / `hd_export` / `report_basic` / `report_regen`),禁止开发期另写一套数字 | FR-BILL-01/02/04/05/06 |
|
||||
| T-P1-26b | DEV | 额度耗尽文案与提示 | 端侧组件 | **不出现任何价格、购买、会员、升级字样**;消耗前提示剩余量 | FR-BILL-07 |
|
||||
| T-P1-27 | DEV | 图片直传(预签名 URL)与临时桶生命周期 | 接口 + 桶策略 | 图片不经应用服务器;2 小时自动清理 | FR-SYS-03、CR-02 |
|
||||
| T-P1-27 | DEV | 图片上传与临时目录生命周期(2026-08-15 变更:原 COS 直传预签名,**E2 已改为本地磁盘 `meida_data/` 四目录并交付**——后端 multipart 中转 + HMAC 短期签名下载 URL;COS 链路保留为回迁路径) | 接口 + 清理任务 | temp 目录 2 小时自动清理(celery beat,CR-02);上传下载闭环 | FR-SYS-03、CR-02 |
|
||||
| T-P1-28 | DEV | 推送接入(Push Kit + Token 管理) | 模块 | 到达率 ≥ 90%;文案无敏感结论 | FR-TASK-04 |
|
||||
|
||||
### 3.4 客户端骨架
|
||||
@@ -429,6 +429,7 @@ M2 知识库 M3 报告闭环 M4-a 换装/发型 │
|
||||
|
||||
| 日期 | 版本 | 变更 |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-16 | v1.6 | **个人开发者降级路线落地(《前置开通清单》v2 的 E1~E4)**:`T-P1-12b/12c` 短信登录→邮箱验证码登录(E1,后端已交付:`packages/identity/email_login.py`,fake/smtp 双通道,防刷参数原样沿用);`T-P1-27` COS 直传→本地磁盘存储(E2,后端已交付:multipart 上传 + HMAC 签名下载 + temp 2h 清理挂 celery beat);`T-P1-04` 腾讯云全家桶收缩。FE 登录页邮箱输入(E3)与联调验证待办 |
|
||||
| 2026-08-14 | v1.5 | **两批多 agent 并行开发交付回写**(任务包见 `开发任务包/`,实测详情见《Sprint 执行计划》§9):`T-P1-25b` 四档熔断(阈值待 8-17 重算)、`T-P1-26` 收尾 claim、`T-P1-10/13/27` 交付、`T-P1-12/12b/12c` 登录双通道代码先行(联调待凭证)、`T-P2-01~06` 知识库三件套交付(BGE-M3 真模型待接)、`T-P0-16` ✅(2.8ms)、`T-P0-10/12` SPIKE 初步结论。**新风险登记:RTMPose 合成图置信度 0.003(域差异),真实照片重验为 `T-P0-10` 前置;BiRefNet CPU 3.1s/张需异步化** |
|
||||
| 2026-08-14 | v1.4 | 交叉评审回写:`T-P1-25b` 验收补「reserve 之后熔断」集成断言与日预算按新价重算;`T-P1-26` 明确种子必须与产品方案 §3.2 一致 |
|
||||
| 2026-08-13 | v1.3 | **后端脚手架落地后的回写**。新增两项由真机实测暴露、原 WBS 完全未覆盖的任务:**`T-P1-24a` 思考模式按场景显式配置** —— `deepseek-v4-flash` **默认开启**思考模式,实测同一单句问答开启 13.5s、关闭 1.8s,思考 token 还按输出价计费,漏配会同时打破 `NFR-PERF-02b` 与成本模型;**`T-P1-25c` 峰谷单价按调用时刻计价** —— DeepSeek 自 2026-08-17 起高峰单价为空闲时段 2 倍,用单一均价记账会让 `ob_ai_call` 与真实账单差出近一倍,而它是 V2 定价的唯一依据。另:`T-P1-01`/`02`/`06`/`07`/`24`/`25` 已在 `meida_backend` 仓库交付并实测通过,各任务的完成状态见《Sprint 执行计划》§1.1 |
|
||||
|
||||
@@ -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 签名出包
|
||||
@@ -0,0 +1,308 @@
|
||||
# 美搭 —— 试衣间 v2 / 发型页 v2 / 美学诊断 UI 设计规范
|
||||
|
||||
> 版本 v1.0(2026-09-02)。范围:试衣间改版、发型页改版、美学诊断落地页与填写页(均为新增/重设计)。
|
||||
> 开发照本文执行,不再另行口头约定;与现有页面冲突处以本文为准。
|
||||
> 基线代码:core/Theme.ets、pages/HomePage.ets、pages/HairDesignPage.ets、pages/TryonPage.ets。
|
||||
|
||||
## 0. 现状勘误与落点
|
||||
|
||||
- `pages/FittingRoomPage.ets` **不存在**。试衣间现状是 `pages/TryonPage.ets`:三槽上传表单(全身照/上衣/裤子)+ 提示词 + 生成等待,非 v2 目标形态。
|
||||
- 试衣间 v2 建议新建 `pages/FittingRoomPage.ets` 承载衣橱网格形态;旧 TryonPage 在 v2 验收通过前保留,首页入口卡切换指向。
|
||||
- 发型页 v2 就地改造 `pages/HairDesignPage.ets`,保留:点按对比、人脸授权弹层(components/f2/FacePhotoConsent)、结果落盘(filesDir/results)。
|
||||
- 美学诊断两页新建:`pages/DiagnosisLandingPage.ets`、`pages/DiagnosisFormPage.ets`。
|
||||
- 滚轮选择器先例:`pages/ReportCreatePage.ets` 的 `getUIContext().showTextPickerDialog({ range, selected, canLoop:false })`,身高/体重直接复用此交互。
|
||||
|
||||
## 1. 色板 Token
|
||||
|
||||
一级来源 `core/Theme.ets`,页面内禁止再散落硬编码主色;下表「新增」色仅落地页 hero 与中性灰,收口于 Theme.ets 补常量。
|
||||
|
||||
| Token | 值 | 用途 | 使用页面 |
|
||||
|---|---|---|---|
|
||||
| COLOR_PRIMARY(现有) | #C2185B | 主按钮、选中态、强调文字、下划线 Tab、删除外的红字错误提示沿用 | 全部 |
|
||||
| COLOR_PRIMARY_SOFT(现有) | #F7E3EA | 次级按钮底、选中浅底 | 全部 |
|
||||
| COLOR_TEXT_PRIMARY(现有) | #222222 | 标题/正文 | 全部 |
|
||||
| COLOR_TEXT_SECONDARY(现有) | #8A9099 | 辅助说明、次级文字 | 全部 |
|
||||
| COLOR_TEXT_DISABLED(新增收口) | #C2C7CE | 禁用文字、占位箭头(现散落 HomePage,收进 Theme) | 全部 |
|
||||
| COLOR_PAGE_BG(新增收口) | #F5F6F8 | 页面底色(现各页各自定义,收进 Theme) | 全部 |
|
||||
| COLOR_DIVIDER(新增收口) | #E5E7EB | 分隔线、chip 描边 | 全部 |
|
||||
| COLOR_IMAGE_BG(新增收口) | #EDEFF3 | 图片占位底 / 未选中段底 | 试衣间、发型 |
|
||||
| COLOR_BLACK_CAPSULE(新增) | #1A1A1A | 「试穿」胶囊按钮、品类 chip 选中态(黑底白字) | 试衣间 |
|
||||
| COLOR_BADGE_BG(新增) | 0x99000000(黑 60% 透明) | 图上角标(模特/删除/原图|效果图)底 | 试衣间、发型 |
|
||||
| COLOR_DIAG_HERO_TOP(新增) | #7C4DFF | 美学诊断落地页 hero 渐变起点 | 仅落地页 |
|
||||
| COLOR_DIAG_HERO_BOTTOM(新增) | #5E35B1 | 落地页 hero 渐变终点 | 仅落地页 |
|
||||
| COLOR_ERROR(新增收口) | #C0392B | 错误文案(HairDesignPage 已硬编码,收进 Theme) | 全部 |
|
||||
|
||||
**裁决说明**:参考站紫调(#7C4DFF→#5E35B1)与 App 主色玫红 #C2185B 不同系。仅落地页 hero 使用独立紫渐变(与 AI 顾问紫 #6C5CE7 同为点缀系、不冲突);落地页其余元素、CTA 按钮、其余三页一律走玫红主色,不跟紫。
|
||||
|
||||
## 2. 基础尺度(四页统一)
|
||||
|
||||
- 页面左右边距 16;卡片内边距 14~16;列表/网格间距 12;同类元素组内间距 8。
|
||||
- 圆角:大卡片 16、图片卡/小卡 12、胶囊(按钮/chip)=高度一半、角标圆形。
|
||||
- 字号字重:页大标题 26 Bold;区块标题 16 Medium;正文/行标签 14 Regular;辅助/品类小字 12;角标/步骤标签 11。
|
||||
- 主按钮统一:宽 100%-32(或吸底减边距)、高 48、圆角 24、底 COLOR_PRIMARY、白字 16 Medium;禁用态底 #E5E7EB、字 #C2C7CE。
|
||||
- 图片一律 `ImageFit.Cover`;加载中占位底 COLOR_IMAGE_BG + LoadingProgress(色 COLOR_PRIMARY,36vp)。
|
||||
|
||||
## 3. 页面一:试衣间 v2(FittingRoomPage)
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ ‹ 返回 试衣间 │ 顶栏:白底,高48,标题17 Medium
|
||||
├──────────────────────────────┤
|
||||
│ ┌──────────────────────────┐ │ 预览区:高=屏45%(≥320vp)
|
||||
│ │[模特] ╲ │ │ 图片 Cover,圆角16,边距16
|
||||
│ │ ┌────┐ │ │ 左上角标「模特」:黑60%胶囊,
|
||||
│ │ │试穿 │ │ │ 白字12,内边距8×12,距边12
|
||||
│ │ └────┘ │ │ 「试穿」:黑胶囊#1A1A1A 白字14,
|
||||
│ │ ╱ │ │ 右缘悬浮垂直居中,距右16
|
||||
│ └──────────────────────────┘ │ 点图=生成后原图/效果切换(沿用)
|
||||
├──────────────────────────────┤
|
||||
│ 我的衣橱 │ Tab 行:仅1个Tab;选中16 Medium
|
||||
│ ───── │ 主色+3vp下划线(圆角2);占位留扩展
|
||||
├──────────────────────────────┤
|
||||
│ (全部)(上衣)(下装)(连衣裙)(外套)(套装)→ │ 品类条:横向Scroll,高32
|
||||
├──────────────────────────────┤
|
||||
│ ┌────────┐ ┌────────┐ │ 双列网格:列距12 行距12
|
||||
│ │图 Cover│ │图 Cover│ [×] │ 服装卡:白底圆角12
|
||||
│ │ │ │ │ │ 图 aspectRatio 3:4 Cover
|
||||
│ │名称 │ │名称 │ │ 名称13 Medium 主色文,单行截断
|
||||
│ │品类小字│ │品类小字│ │ 品类11 Secondary
|
||||
│ └────────┘ └────────┘ │ 删除角标:右上20×20圆 黑60%
|
||||
├──────────────────────────────┤
|
||||
│ 空态: (空衣橱插图区) │ 空态居中:图标44 Disabled色
|
||||
│ 衣橱还是空的 │ 标题14 Primary + 副文案12 Secondary
|
||||
│ [ 从相册选一件衣服 ] │ 大按钮:主色 高48 圆角24
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
状态:生成中→预览区 Loading+状态文案(沿用 TryonPage 计时/挽留口径);服装卡点击=选中试穿(描边 2vp COLOR_PRIMARY);删除角标点击需二次确认弹窗(「移除这件衣服?」取消/移除)。
|
||||
|
||||
## 4. 页面二:发型页 v2(HairDesignPage 改造)
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ ‹ 返回 发型设计 │ 顶栏同上
|
||||
├──────────────────────────────┤
|
||||
│ ┌────────────┐ ┌────────────┐│ 双图区:两图等宽,高=屏40%
|
||||
│ │ 原图 │ │ 效果图 ││ (≥240vp),间距12,圆角12
|
||||
│ │[原图|效果图]│ │ ││ 左下小角标标注当前显示态:
|
||||
│ └────────────┘ └────────────┘│ 黑60%胶囊白字11;点右图切换对比
|
||||
│ 相册 拍照 │ 入口文字链 12 Primary(沿用)
|
||||
├──────────────────────────────┤
|
||||
│ ╭──────╮──────╮ │ 性别分段控件:容器COLOR_IMAGE_BG
|
||||
│ │ 女生 │ 男生 │ │ 圆角18 内边距2;选中段主色底
|
||||
│ ╰──────╯──────╯ │ 白字圆角16,未选透明底深字13
|
||||
├──────────────────────────────┤
|
||||
│ 选择发型(点选即试戴) │ 三列网格沿用现状(不动)
|
||||
│ ▣ ▣ ▣ / ▣ ▣ ▣ / ▣ ▣ ▣ │ 选中描边2vp主色(沿用)
|
||||
├──────────────────────────────┤
|
||||
│ 发色 ● ● ● ● ● ● ● ● │ 8色圆点:32vp圆,名称11在点下
|
||||
│ 自然黑 深棕 栗色 蜜茶… │ 选中:外环2vp COLOR_PRIMARY
|
||||
├──────────────────────────────┤
|
||||
│ 强度 ────●──────── 0.9 │ 行:标签12 Secondary+Slider+值
|
||||
├──────────────────────────────┤
|
||||
│ [ 应用发色 ] │ 吸底主按钮(白底条+顶分隔线)
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
变化点:①发色由文字 chip 改为「圆点+名称」竖排小项(色值沿用 PRESET_COLORS 8 组 RGB);②「应用发色」由滚动区内改为吸底;③无结果时发色/强度区整体隐藏(沿用),按钮禁用态见 §2。上次结果卡、三态(加载/空/失败)、人脸授权弹层全部沿用不动。
|
||||
|
||||
## 5. 页面三:美学诊断落地页(DiagnosisLandingPage)
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ ██████ 紫渐变 hero █████████ │ hero:#7C4DFF→#5E35B1 线性渐变
|
||||
│ │ 高=屏38%,圆角16(四周留边16)
|
||||
│ 个人形象风格分析报告 │ 大标题 白 26 Bold,左上角内边距24
|
||||
│ 5 分钟,看清你的穿衣风格 │ 副标 白85% 13
|
||||
├──────────────────────────────┤
|
||||
│ 你的穿衣困扰 │ 区块标题16 Medium
|
||||
│ ┌──────────────────────────┐ │ 清单卡:白底圆角12 内边距16
|
||||
│ │ ● 不知道自己适合什么风格 │ │ 5 行,行高≥40,行间8
|
||||
│ │ ● 衣柜很多衣服却不会搭 │ │ 每行:主色圆点8vp+文字14
|
||||
│ │ ● 穿显胖/显黑,不敢尝试 │ │
|
||||
│ │ ● 想改变却没有方向 │ │
|
||||
│ │ ● 每次出门搭配浪费时间 │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ 如何生成你的报告 │ 区块标题16 Medium
|
||||
│ ①┄②┄③┄④ │ 横向步骤条:4步等宽
|
||||
│ 测一测 分析 生成 报告 │ 圆点28vp(当前主色底白字/未选
|
||||
│ │ IMAGE_BG底Secondary字),连线
|
||||
│ │ 2vp #E5E7EB,标签11居中
|
||||
├──────────────────────────────┤
|
||||
│ [ 马上体验 ] │ 吸底 CTA:主色玫红(非紫)高48
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
CTA 点击 → router 跳 DiagnosisFormPage。hero 为该页专属装饰,报告页/表单页不出现紫渐变。
|
||||
|
||||
## 6. 页面四:美学诊断填写页(DiagnosisFormPage)
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ ‹ 返回 形象信息 │ 顶栏同上
|
||||
├──────────────────────────────┤
|
||||
│ 基本信息 │ 区块标题16 Medium,上边距16
|
||||
│ ┌──────────────────────────┐ │ 分组卡:白底圆角12 内边距16
|
||||
│ │ 性别 [男 | 女] │ │ 行高52,行间分隔线0.5vp
|
||||
│ │ 出生日期 1995-06-01 ›│ 分段控件复用§4;未选占位
|
||||
│ │ 身高 165 cm ›│ 「请选择」Secondary色
|
||||
│ │ 体重 55 kg ›│ 行:标签14左,值14右+›Disabled
|
||||
│ │ 职业 请输入 ›│ 文本框右对齐无边框,占位同上
|
||||
│ └──────────────────────────┘ │
|
||||
│ 信息仅用于生成你的个人报告, │ 隐私说明:11 Secondary,左对齐
|
||||
│ 不会对外展示或共享。 │ 卡下边距8
|
||||
│ │ 中部留白(后续扩展题目区)
|
||||
├──────────────────────────────┤
|
||||
│ [ 下一步 ] │ 吸底主按钮:主色 高48
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
- 滚轮口径(全部复用 showTextPickerDialog,canLoop:false):出生日期 range 为「1950~2010 年」年份单列或 YYYY-MM 串(开发定,先例一致即可);身高 100~250 整数 cm,默认 165;体重 25~300 整数 kg,默认 55;已有值从当前值滚起(沿用 ReportCreatePage 的 clamp 写法)。
|
||||
- 校验:性别/出生日期/身高/体重必填,职业可空;缺失字段红字 12 COLOR_ERROR 行下提示 + 汇总条「请完成:…」并滚动到首个缺失项(沿用 ReportCreatePage 口径);「下一步」全部合法后才可跳转结果流程。
|
||||
|
||||
## 7. 组件清单(新建,一律独立文件、@Component 导出)
|
||||
|
||||
| 组件 | 建议文件 | 复用方 |
|
||||
|---|---|---|
|
||||
| CategoryChip 品类 chip | components/f2/CategoryChip.ets | 试衣间 v2 |
|
||||
| ClothCard 服装卡(图+名称+品类+删除角标) | components/f2/ClothCard.ets | 试衣间 v2 |
|
||||
| CapsuleButton 黑胶囊(试穿) | components/f2/CapsuleButton.ets | 试衣间 v2 |
|
||||
| Segment 分段控件(两枚胶囊,参数 items/selected) | components/f2/Segment.ets | 发型页、诊断表单 |
|
||||
| ColorDot 发色圆点+名称 | components/f2/ColorDot.ets | 发型页 |
|
||||
| StepBar 横向步骤条 | components/f3/StepBar.ets | 诊断落地页 |
|
||||
| FormRow 表单行(标签+值/控件+箭头+错误行) | components/f3/FormRow.ets | 诊断表单(ReportCreatePage 后续可迁) |
|
||||
|
||||
放置依据:f2=试衣间/发型域(FacePhotoConsent 已在此),f3=报告域(诊断归报告旅程)。通用基础件(Segment、FormRow)先放域内,出现第二域复用时再上收 core/。
|
||||
|
||||
## 8. 文案表(原文照抄,开发不得改写)
|
||||
|
||||
| 位置 | 文案 |
|
||||
|---|---|
|
||||
| 试衣间·页标题 | 试衣间 |
|
||||
| 试衣间·图上角标 | 模特 |
|
||||
| 试衣间·悬浮按钮 | 试穿 |
|
||||
| 试衣间·Tab | 我的衣橱 |
|
||||
| 试衣间·品类 chips | 全部 / 上衣 / 下装 / 连衣裙 / 外套 / 套装 |
|
||||
| 试衣间·空态标题/副文/按钮 | 衣橱还是空的 / 从相册挑一件,马上试穿效果 / 从相册选一件衣服 |
|
||||
| 试衣间·删除确认 | 移除这件衣服? / 取消 / 移除 |
|
||||
| 发型页·双图角标 | 原图 / 效果图 |
|
||||
| 发型页·分段控件 | 女生 / 男生 |
|
||||
| 发型页·区块标题 | 选择发型(点选即试戴) / 换个发色 / 强度 |
|
||||
| 发型页·主按钮 | 应用发色(生成中→生成中…) |
|
||||
| 发型页·图下提示 | 请使用本人照片,上传即确认不侵犯他人肖像权 |
|
||||
| 落地页·大标题/副标 | 个人形象风格分析报告 / 5 分钟,看清你的穿衣风格 |
|
||||
| 落地页·区块标题 | 你的穿衣困扰 / 如何生成你的报告 |
|
||||
| 落地页·困扰 5 条 | 不知道自己适合什么风格 / 衣柜很多衣服却不会搭 / 穿显胖显黑,不敢尝试 / 想改变却没有方向 / 每次出门搭配浪费时间 |
|
||||
| 落地页·步骤条 | 测一测 / 分析 / 生成 / 报告 |
|
||||
| 落地页·CTA | 马上体验 |
|
||||
| 表单页·页标题/区块 | 形象信息 / 基本信息 |
|
||||
| 表单页·行标签 | 性别 / 出生日期 / 身高 / 体重 / 职业 |
|
||||
| 表单页·分段 | 男 / 女;占位:请选择 / 请输入 |
|
||||
| 表单页·隐私说明 | 信息仅用于生成你的个人报告,不会对外展示或共享。 |
|
||||
| 表单页·主按钮 | 下一步 |
|
||||
| 表单页·缺失汇总 | 请完成:性别、出生日期…(动态拼接) |
|
||||
|
||||
## 9. 验收清单(逐条可勾)
|
||||
|
||||
1. 四页主色唯一来源 Theme.ets;落地页外无紫色,除 #1A1A1A 胶囊外无新增散落色值。
|
||||
2. 试衣间 v2:预览区约占屏 45%、双列网格、空态引导按钮、品类条横向可滚且选中黑底白字。
|
||||
3. 发型页 v2:双图+对比、分段控件、8 色圆点+名称、强度滑杆、吸底「应用发色」;旧三态与授权弹层行为不回归。
|
||||
4. 落地页:紫渐变 hero、5 条困扰卡、4 步步骤条、吸底「马上体验」跳填写页。
|
||||
5. 填写页:分组白卡表单、三个滚轮范围正确(100-250 / 25-300 / 年份)、必填校验+汇总条+滚动定位、隐私说明可见。
|
||||
|
||||
## 10. 试衣间布局 v2.1(FittingRoomPage 紧凑预览改版)
|
||||
|
||||
> 用户诉求:①预览区(现 3:4 大图 437vp、占屏约 57%)弄小;②我的/精选衣橱区弄大;③少滚动即可浏览选衣。
|
||||
> 本节与 §3 预览区规格、§9 验收 2 冲突处**以本节为准**,其余全部沿用。逻辑屏按 360×788vp 计算。
|
||||
|
||||
### 10.1 五项裁决
|
||||
|
||||
1. **预览 vs 不裁切 = 方案 a+c+d 组合**:预览固定高 **300vp** + `ImageFit.Contain` + 底 COLOR_IMAGE_BG(人像 3:4 渲染约 225×300 居中,两侧灰边各≈51vp)。理由:试穿必须看全全身,Cover 裁切直接劣化选衣判断且用户已投诉过「被裁切」,故预览态放弃 Cover(成为 §2 全 Cover 规则的唯一例外);小图看不全→**点图开全屏查看器**(§10.3);**生成完成后自动弹出查看器一次**(genSeq 守卫:取消/离开/迟到失效不弹,用户关闭后不重复弹)。空态(未传本人照)预览仍 300vp,占位内容居中沿用。
|
||||
2. **网格三列、纯图卡**:列/行距 8;卡宽≈104、图 104×139(3:4 Cover)。**去掉名称与品类两行文字**(104vp 窄卡放不下、品类已可由 chips 过滤),品类改为图内左下小角标;槽位角标与删除 × 保留右上。首屏可见 ≥2 整行=6 卡(原双列带文字约 3 卡)。
|
||||
3. **生成中/结果态**:生成中覆盖层压在 300vp 预览内(黑 60%):Loading 36 + 状态文案 12(最多 2 行)+ 秒数 16 + 「取消生成」高 28;结果操作行「保存到相册/再来一次」保留在预览下方原位不动;查看器内另设「保存到相册」主按钮。
|
||||
4. **胶囊/角标缩小**:「试穿」黑胶囊 **44×96**(原 52×120)、圆角 22、白字 14,右缘距预览右边 12、垂直居中,置灰条件沿用;「模特/效果图」角标字号 12→**11**、内边距 10×6、距边 10(对齐 §2 角标 11)。
|
||||
5. **全屏查看器**:全屏覆盖层、底 COLOR_BLACK_CAPSULE(#1A1A1A,复用不新增色值);与页面**共用 `showOriginal` 状态**——页签、预览左上角标、图源三处同步。
|
||||
|
||||
### 10.2 整页线框(紧凑预览版)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐ 360vp
|
||||
│ ‹ 返回 试衣间 │ 顶栏48 沿用
|
||||
├──────────────────────────────────┤
|
||||
│ ┌──────────────────────────────┐ │ 预览卡:margin16/8 高300 固定
|
||||
│ │[模特]11 ┌────┐ │ │ 底#EDEFF3 圆角16 clip
|
||||
│ │ ┌───────────┐ │试 │ │ │ 图 Contain 225×300 居中
|
||||
│ │ │ 全身完整 │ │穿 │ │ │ 两侧灰边各≈51 不裁切
|
||||
│ │ │ 不裁切 │ └────┘ │ │ 点图→全屏查看器(§10.3)
|
||||
│ │ └───────────┘ 44×96 │ │ 生成中:黑60%覆盖层
|
||||
│ └──────────────────────────────┘ │
|
||||
│ 请先选择一件上衣… / 状态/合规提示 │ 12 条件行 ≤20vp(沿用)
|
||||
├──────────────────────────────────┤
|
||||
│ 我的衣橱 精选衣橱 │ Tab32 沿用(主色3vp下划线)
|
||||
│ (全部)(上衣)(下装)(连衣裙)(外套)(套装)→│ chips高28 横滚 选中黑底白字
|
||||
├──────────────────────────────────┤
|
||||
│ ┌──────┐ ┌──────┐ ┌──────┐ │ 三列:列距8 行距8
|
||||
│ │ 图3:4 │ │ 图3:4 │[×] 图3:4 │ │ 卡宽104 图104×139 Cover
|
||||
│ │[上衣] │ │[下装] │ │ │ │ 左下品类角标(黑60%字10)
|
||||
│ └──────┘ └──────┘ └──────┘ │ 选中2vp主色描边+右上槽位角标
|
||||
│ ┌──────┐ ┌──────┐ ┌──────┐ │ 第2行 首屏完整可见
|
||||
│ │ 图 │ │ + │ │ 图 │ │ 末位添加卡:+24/添加12
|
||||
│ └──────┘ └──────┘ └──────┘ │ 底部「拍照」11 主色
|
||||
├────── ↓ 以下滚动 ↓ ───────────────┤
|
||||
│ 保存到相册 再来一次 │ 结果操作行(条件,沿用)
|
||||
│ 连衣裙、外套、套装按上衣流程… │ 脚注11+高级提示词+上次结果卡
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
垂直预算(788vp):顶栏 48 + 预览块 316(图 300+上下边距 16,占屏 40%,原 453/57%)+ 提示行 ≤20 + 滚动区 ≥404(Tab 32 + chips 36 + 网格首屏 ≥2 整行 286)。
|
||||
|
||||
### 10.3 全屏查看器线框
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐ 全屏覆盖层 底#1A1A1A
|
||||
│ × 原图 效果图 │ 顶56:×白24 距左16;页签白13
|
||||
│ ───── │ 当前=白+3vp白下划线;未选白60%
|
||||
│ │ 仅 resultUrl 非空显页签;
|
||||
│ ┌────────────┐ │ 模特态仅「×」无页签
|
||||
│ │ 原图/效果图 │ │ 图 Contain 宽100%-32 居中
|
||||
│ │ 3:4 居中 │ │ 高=顶部56与底区之外全部
|
||||
│ └────────────┘ │ 点图=切原图/效果(共用
|
||||
│ │ showOriginal,关闭后预览同步)
|
||||
│ [ 保存到相册 ] │ 主按钮 高48 圆角24 主色白字16
|
||||
│ 再来一次 │ 白13 文字链(仅结果态显示)
|
||||
└──────────────────────────────────┘
|
||||
关闭:点 × / 系统返回;保存成功回填状态文案「已保存到相册」(沿用三态口径)。
|
||||
```
|
||||
|
||||
### 10.4 尺寸与 §2/§3 冲突修订清单
|
||||
|
||||
| # | 原规则 | v2.1 修订 |
|
||||
|---|---|---|
|
||||
| 1 | §2 图片一律 ImageFit.Cover | 试衣间预览/查看器改 **Contain**(唯一例外) |
|
||||
| 2 | §2 列表/网格间距 12 | 试衣间网格列/行距 **8** |
|
||||
| 3 | §3 预览区高=屏 45% | 固定 **300vp**(含边距占屏 40%) |
|
||||
| 4 | §3 双列 3:4 卡+名称13+品类11 | **三列纯图卡**(104×139,无文字行) |
|
||||
| 5 | §3 角标白字 12、内边距 8×12、距边 12 | 字 **11**、内边距 10×6、距边 10 |
|
||||
| 6 | §3「试穿」胶囊 52×120 | **44×96** 圆角 22 |
|
||||
| 7 | §3 品类条 chips 高 32 | **28**(字 12、内边距 10×6、圆角 14) |
|
||||
|
||||
新增组件:**ImageViewer**(components/f2/ImageViewer.ets,@Component 导出;参数:imageSrc、hasResult、showOriginal 双向、onSave、onRetry、onClose)。
|
||||
|
||||
### 10.5 新增文案(逐字,其余沿用 §8)
|
||||
|
||||
| 位置 | 文案 |
|
||||
|---|---|
|
||||
| 试衣间·查看器页签 | 原图 / 效果图 |
|
||||
| 试衣间·查看器关闭 | ×(图标,无朗读文案需求,无文字) |
|
||||
| 试衣间·卡片品类角标 | 沿用品类词:上衣 / 下装 / 连衣裙 / 外套 / 套装 |
|
||||
|
||||
### 10.6 验收清单(可自动化判定)
|
||||
|
||||
1. 360×788vp 下预览块高(含上下边距)=316vp 固定;图 ImageFit.Contain、底 #EDEFF3、无裁切。
|
||||
2. 无状态提示行时,衣橱网格首屏完整可见 2 行 × 3 列(≥6 卡),Tab/chips 无需滚动。
|
||||
3. 网格 3 列、列/行距 8;服装卡无名称/品类文字行、左下品类角标;选中态 2vp #C2185B 描边+右上槽位角标;我的衣橱卡保留 20×20 删除。
|
||||
4. 点预览图弹出全屏查看器;有结果时查看器页签与预览左上角标文案一致(同一 showOriginal 状态驱动)。
|
||||
5. 生成完成自动打开查看器恰一次(取消/离开/迟到失效不弹);查看器「保存到相册」走系统保存对话框三态口径。
|
||||
6. 「试穿」胶囊 44×96 右缘悬浮、未选上衣/生成中置灰不可点(沿用);生成中覆盖层四要素(Loading/文案≤2行/秒数/取消)在 300vp 内完整显示。
|
||||
7. 「+添加」卡在网格末位,含相册(点卡)与拍照(底部链接)两入口(沿用)。
|
||||
8. 除查看器底与胶囊复用 #1A1A1A 外无新增色值;§3/§9.2 与本节冲突处按本表 §10.4 执行。
|
||||
@@ -0,0 +1,181 @@
|
||||
# 美搭配与美学诊断 · 需求文档 v2
|
||||
|
||||
> 2026-09-02 | 状态:待评审 | 关联:《试衣间与发型设计-产品方案》《鸿蒙美搭-智能穿搭美学助手-产品方案》§2.1、《美学报告-需求文档》v1.2
|
||||
> 目标形态:参考站 fitting-room.huimeimeta.com H5(试衣间页 / 发型品类形态 / 美学诊断落地页 + 信息填写页)。
|
||||
|
||||
## 1. 背景与范围
|
||||
|
||||
现有 Phase A 已交付:试衣间(本人照+上衣±裤子→换装,直连 meida.xiangsilian.com/change_cloth_base64)、发型设计(面部特写→60 款试戴+8 色换色)、美学报告(六字段问卷→32 维报告)。
|
||||
|
||||
本 v2 三件事:
|
||||
1. **试衣间改版**(对外名「美搭配」):视觉与交互对齐参考站(顶部预览+试穿按钮+衣橱 Tab+品类条+双列网格);
|
||||
2. **发型设计改版**:保留现有交互,视觉对齐参考站形态;
|
||||
3. **新增美学诊断**:营销落地页 + 信息填写页,复用现有报告创建链路。
|
||||
|
||||
Phase A 约束:无后端改造、无账号体系升级,全部改动在客户端。
|
||||
|
||||
## 2. 现状 vs 目标差距表
|
||||
|
||||
### 2.1 试衣间(美搭配)
|
||||
|
||||
| 维度 | 现状(TryonPage v1) | 目标(v2,对齐参考站) |
|
||||
|---|---|---|
|
||||
| 布局 | 三张照片槽(全身照/上衣/裤子)纵排表单 | 顶部大图模特预览 + 右侧悬浮黑色「试穿」按钮 + 下方衣橱区 |
|
||||
| 素材来源 | 逐张上传/拍摄 | 「我的衣橱」双列网格点选;模特位=本人照上传(拍/选) |
|
||||
| 品类 | 无品类概念(固定两槽) | 品类条:全部/上衣/下装/连衣裙/外套/套装(发型不在本页) |
|
||||
| Tab | 无 | 「我的衣橱|赴宴试衣间」双 Tab |
|
||||
| 模特切换 | 无 | 参考站支持左右滑切换模特;Phase A 仅本人照,**无模特库、无滑动切换**(见 §3.2) |
|
||||
| 生成链路 | 不变 | 不变(接口、超时 320s、秒数等待、常亮、挽留、本机留存均沿用) |
|
||||
|
||||
### 2.2 发型设计
|
||||
|
||||
| 维度 | 现状(HairDesignPage v1) | 目标(v2) |
|
||||
|---|---|---|
|
||||
| 布局 | 照片槽 + 男女分栏网格 + 换发色面板 | 同参考站形态:顶部预览大图 + 品类条(全部/男发/女发)+ 双列网格;换发色面板改为网格下方可展开卡片 |
|
||||
| 交互 | 点选即试戴、8 色发色、强度滑杆、男女分栏(女 34/男 26) | **全部保留**,仅视觉重排 |
|
||||
| 数据 | 在线拉 60 款 | 不变 |
|
||||
|
||||
### 2.3 美学诊断(新增)
|
||||
|
||||
| 维度 | 现状 | 目标(v2) |
|
||||
|---|---|---|
|
||||
| 入口 | 首页入口卡「生成我的形象报告」直达问卷页 | 入口卡改跳「美学诊断落地页」(营销态) |
|
||||
| 落地页 | 无 | 紫色调静态页:主张+PROBLEM+REASON+4 步流程+3 价值卡+CTA「马上体验」 |
|
||||
| 信息填写 | 无(问卷页直接六字段) | 白底「AI 美学检测」表单:性别/出生日期/身高/体重/职业 →「下一步」预填进现有问卷页 |
|
||||
|
||||
## 3. Phase A 技术约束下的产品裁决
|
||||
|
||||
### 3.1 品类 → 接口工作流的降级映射(重要)
|
||||
|
||||
换装接口仅支持「上衣(cloth_img,必传)+ 裤子(kuzi_img,可选且必须伴随上衣)」。参考站的 6 品类按下表**两档映射**,UI 不暴露工作流差异:
|
||||
|
||||
| 品类标签 | 工作流档位 | 说明 |
|
||||
|---|---|---|
|
||||
| 上衣 | cloth_img | 标准档 |
|
||||
| 下装 | kuzi_img | 仅可与上衣同时选中;未选上衣时点选提示「请先选择一件上衣,再搭配下装」 |
|
||||
| 连衣裙 / 外套 / 套装 | **归入 cloth_img(上衣档)** | 降级声明:按单件上衣工作流处理,效果可能不完整(如下摆/成套感缺失),品类条上对这三类加「试穿效果以实际生成为准」的页面脚注 |
|
||||
|
||||
选中模型:**双槽位**。上衣槽(含连衣裙/外套/套装)与下装槽各单选、可替换、可取消;「试穿」按钮可用条件=上衣槽非空。生成时按槽位组装 cloth_img/kuzi_img。
|
||||
|
||||
### 3.2 模特位:仅本人照片
|
||||
|
||||
- 「模特」=用户本人大图预览,**不做模特库、无左右滑动切换**(参考站该交互依赖内置模特素材与授权,Phase A 均无)。
|
||||
- 预览空态:占位图+「上传/拍摄你的正面全身照」按钮;点击走现有 FacePhotoConsent 流程。
|
||||
- 预览有图时:右侧悬浮「试穿」按钮(黑底白字、圆角、高约 120vp 竖条,对齐参考站);未选上衣时按钮置灰。
|
||||
|
||||
### 3.3 我的衣橱 / 赴宴试衣间
|
||||
|
||||
- **我的衣橱**(默认 Tab)=用户本机上传的服装:元数据(品类标签、添加时间)写 preferences,图片文件落 filesDir/wardrobe/,App 卸载即清,不上传服务端。
|
||||
- **上传交互**:衣橱网格末位常驻「+ 添加」卡;点击选/拍一张 → 必选品类标签弹层(上衣/下装/连衣裙/外套/套装,五选一,默认无选中,未选不能确认)→ 存入。
|
||||
- **赴宴试衣间**=精选示例服装(内置素材)。**Phase A 无内置素材,该 Tab 直接隐藏**(不显示灰色占位);素材管线(≥300 件 tryon_ready)就绪后再启用,启用时该 Tab 展示双列精选网格,点击行为与我的衣橱一致(点选→试穿)。
|
||||
|
||||
### 3.4 服装库
|
||||
|
||||
Phase A 无内置素材,网格唯一数据源=我的衣橱。空态:居中插画+「衣橱还是空的,拍一件你的衣服试试」+「添加第一件」按钮(点击=上传流程)。
|
||||
|
||||
### 3.5 发型页裁决
|
||||
|
||||
男女分栏(26 男/34 女)、8 色发色(自然黑/深棕/栗色/蜜茶/亚麻/酒红/奶奶灰/蓝黑)、强度滑杆(0~1,默认 0.9)、点选即试戴、换发色作用于当前结果图——交互全部保留;本次只做视觉重排对齐参考站(顶部预览+品类条+网格)。品类条「全部/男发/女发」映射现有 genderTab(全部=unknown 款两栏都显示,沿用)。
|
||||
|
||||
### 3.6 美学诊断:复用报告链路,不重复造接口
|
||||
|
||||
现有 `POST /api/v1/reports` 问卷体六字段:`gender`(女/男/不便告知)、`height_cm`(100~250)、`weight_kg`(25~300)、`birthday`(YYYY-MM-DD)、`core_goals`(六项多选,至少 1)、`psych_style`(九型单选);提交前有配额确认框(report_basic 终身 1 次),随后 3s 轮询任务→结果页。
|
||||
|
||||
**字段映射与预填策略**:
|
||||
|
||||
| 新表单字段 | 映射 | 说明 |
|
||||
|---|---|---|
|
||||
| 性别(男/女 二选切换) | `questionnaire.gender` | 直接映射「男/女」;「不便告知」不在新表单出现,仅原问卷页保留 |
|
||||
| 出生日期 | `questionnaire.birthday` | DatePicker(1930~当天);表单上按年月日展示并派生只读「年龄」 |
|
||||
| 身高 cm | `questionnaire.height_cm` | 沿用滚轮选择器 100~250,不开放键盘 |
|
||||
| 体重 KG | `questionnaire.weight_kg` | 滚轮 25~300 |
|
||||
| 职业 | **Phase A 仅本地留存,不进提交体** | 后端问卷 schema 无 occupation 字段(维度字典 #4 属全面报告),无后端改造前提下不提交;存 AppStorage 供 Phase B 随 schema 扩展提交 |
|
||||
|
||||
**流程**:填写页「下一步」→ `router.pushUrl('pages/ReportCreatePage')` 携带预填参数 `{gender, birthday, height_cm, weight_kg, occupation(本地)}`;ReportCreatePage 新增 aboutToAppear 读参预填对应 @State,用户在原页补齐核心诉求+心理风格后走原提交/配额确认/轮询链路。原问卷页字段与校验不删不改;从首页直达原问卷页的路径取消(入口卡改指落地页)。
|
||||
|
||||
### 3.7 合规红线(沿用,不放松)
|
||||
|
||||
- 仅限本人照片:试衣间模特位、发型页照片均限本人,页面明示「仅限本人照片」;
|
||||
- 人脸单独同意:上传含人脸照片前必须弹 FacePhotoConsent(服装图不涉及);
|
||||
- Phase A 不接入试衣/发型配额:无配额提示、无扣减文案,个人页配额卡保持只读;
|
||||
- 结果图不落服务端库、无分享出口;本机留存沿用 filesDir/results(仅最近一次)。
|
||||
|
||||
## 4. 页面结构定义
|
||||
|
||||
### 4.1 P1 试衣间(美搭配)v2
|
||||
|
||||
| 区块(上→下) | 内容与交互 | 状态 |
|
||||
|---|---|---|
|
||||
| ① 顶部预览区 | 高约 45% 屏;本人全身照大图(cover 裁切);右侧悬浮「试穿」竖按钮(黑底白字,选中 0 件上衣置灰) | 空态:浅灰底+人形占位+「上传/拍摄你的正面全身照」按钮(点击→人脸同意→拍/选);加载中不涉及(本地图即时显示) |
|
||||
| ② Tab 行 | 「我的衣橱|赴宴试衣间」;Phase A 仅渲染我的衣橱(见 §3.3) | — |
|
||||
| ③ 品类条 | 「衣服库」标题+横向滚动 chips:全部/上衣/下装/连衣裙/外套/套装;单选过滤,默认「全部」 | — |
|
||||
| ④ 双列网格 | 衣橱卡片:服装图(1:1 裁切)+ 品类角标;点选=设入对应槽位(选中态描边+右上角「上衣/下装」槽位角标);再点取消;末位「+ 添加」卡 | 空态见 §3.4;图片加载失败显示「已失效,点按删除」占位(本机文件被清时) |
|
||||
| ⑤ 结果区 | 生成中:预览区覆盖层显示已等待秒数+阶段文案+「取消生成」;完成后原图/结果点按切换+「保存到相册」「再来一次」;「上次结果」卡沿用 | 失败:400 提示重拍/换图;502/503/504「服务繁忙稍后重试」;>320s 主动中止提示;均保留已选不覆盖 |
|
||||
| ⑥ 高级项 | 提示词输入,默认折叠(沿用) | — |
|
||||
|
||||
### 4.2 P2 发型设计 v2
|
||||
|
||||
| 区块(上→下) | 内容与交互 | 状态 |
|
||||
|---|---|---|
|
||||
| ① 顶部预览区 | 面部特写大图+右侧悬浮「试戴」按钮(形态同 P1);预览下小字「仅限本人照片」 | 空态:占位+「上传/拍摄面部特写」按钮(→人脸同意);生成中覆盖层:进度文案+秒数,失败保留当前图 |
|
||||
| ② 品类条 | chips:全部/男发/女发(映射现有男女分栏) | — |
|
||||
| ③ 发型网格 | 双列:缩略图+名称;点选即试戴(选中描边);结果回填预览区,点按切换原图/结果 | 列表三态沿用:加载中骨架/空态「暂无发型」/失败+「重新加载」 |
|
||||
| ④ 换发色卡片 | 网格下方可展开卡片「换发色」:8 色圆点+强度滑杆+「应用」;作用于当前结果图 | 未试戴成功前卡片置灰+引导文案「先试戴一款发型」 |
|
||||
|
||||
### 4.3 P3 美学诊断落地页(新页面,纯静态)
|
||||
|
||||
| 区块(上→下) | 内容 |
|
||||
|---|---|
|
||||
| ① 主视觉 | 紫色渐变底;主标「个人形象风格分析报告」;副标「AI 解读你的身材、色彩与风格基因」;顶部返回+标题「美学诊断」 |
|
||||
| ② 穿衣困扰 PROBLEM | 标题「这些困扰你也有吗?」+ 清单卡 6 条(不知道自己适合什么风格 / 衣柜满满却没衣服穿 / 买回的衣服总穿不出效果 / 打扮了还是显老气或不够精致 / 换季搭配毫无头绪 / 不会配色显黑显胖) |
|
||||
| ③ REASON | 一段式说明「问题不在长相,在于没找到属于你的形象公式」 |
|
||||
| ④ 4 步流程 | 步骤条:① 身材形象分析 ② 运动形象分析 ③ 职业形象分析 ④ 专属报告编写(每步一句副文案) |
|
||||
| ⑤ 三张价值卡 | 专属形象基因 / 专属风格系统 / 专属魅力衣橱(各一句说明) |
|
||||
| ⑥ 底部 CTA | 通栏紫色按钮「马上体验」→ P4;CTA 上方小字「我们会保障您的个人信息安全」 |
|
||||
|
||||
无任何网络请求;弱网/失败态不适用。
|
||||
|
||||
### 4.4 P4 美学诊断信息填写页(新页面)
|
||||
|
||||
| 区块(上→下) | 内容与交互 | 状态 |
|
||||
|---|---|---|
|
||||
| ① 标题区 | 白底;「AI 美学检测」大标+「我们会保障您的个人信息安全」副标 | — |
|
||||
| ② 性别 | 「男/女」两粒切换胶囊(单选必填,默认不选) | 未选提交→红字「请选择性别」 |
|
||||
| ③ 出生日期 | 点击弹 DatePicker(1930~今天);行内展示「1998-06-15(26 岁)」 | 未选提交→红字「请选择出生日期」 |
|
||||
| ④ 身高 cm | 滚轮选择器 100~250 | 未选提交→红字「请选择身高」 |
|
||||
| ⑤ 体重 KG | 滚轮选择器 25~300 | 未选提交→红字「请选择体重」 |
|
||||
| ⑥ 职业 | 常用职业 chips 单选(学生/上班族/教师/医护/公务员/自由职业/其他)+选填 | 可不选 |
|
||||
| ⑦ 底部「下一步」 | 通栏按钮;校验失败时汇总条点名缺项并滚至首个缺失字段(沿用原问卷页交互);通过→预填跳 ReportCreatePage | 跳转失败:页内红字兜底提示 |
|
||||
|
||||
说明:进入 P4 即视为同意生成报告;配额确认仍发生在 ReportCreatePage 提交前(禁止静默扣减纪律不变)。已生成过报告的用户走完流程会在提交时命中「本期报告已生成」对话框(沿用 40920 处理),落地页不做二次拦截。
|
||||
|
||||
## 5. 验收标准(可自动化判定)
|
||||
|
||||
**P1 试衣间**
|
||||
1. 首次进入且未上传本人照:预览区显示占位文案与上传按钮,且「试穿」按钮不可点击;
|
||||
2. 衣橱上传一张图并选「连衣裙」标签:网格出现该卡,品类条切「连衣裙」可见、切「上衣」不可见;
|
||||
3. 仅选下装未选上衣:「试穿」不可用且出现「请先选择一件上衣,再搭配下装」提示;
|
||||
4. 选 1 件上衣+1 件下装点「试穿」:请求体 cloth_img 与 kuzi_img 均非空;仅选上衣时 kuzi_img 为空;
|
||||
5. 衣橱为空(清 preferences)时显示空态文案与「添加第一件」按钮。
|
||||
|
||||
**P2 发型**
|
||||
1. 列表加载完成:品类条「女发」项数=34、「男发」项数=26(±unknown 款两栏均含);
|
||||
2. 未试戴成功时「换发色」卡片置灰不可展开;
|
||||
3. 试戴成功后预览区可点按切换原图/结果;
|
||||
4. 照片上传前未通过人脸同意时不弹出相册/拍照选择器。
|
||||
|
||||
**P3/P4 美学诊断**
|
||||
1. 落地页渲染 6 条 PROBLEM、4 步流程、3 张价值卡,CTA 点击跳转 P4;
|
||||
2. P4 五项必填校验:任一未填点「下一步」,出现对应红字与汇总条且不发生路由跳转;
|
||||
3. 填写「女/1998-06-15/165/52」后「下一步」:ReportCreatePage 的性别=女、生日=1998-06-15、身高=165、体重=52 已预填,核心诉求与心理风格为空待用户填写;
|
||||
4. 职业任意选择不改变提交报文中 questionnaire 字段集合(无 occupation 键)。
|
||||
|
||||
## 6. 非目标(Phase B 及以后)
|
||||
|
||||
1. 模特库与左右滑动切换模特;
|
||||
2. 「赴宴试衣间」精选素材与该 Tab 启用;
|
||||
3. 电商链接/购买入口;
|
||||
4. AI 试衣配额接入与扣减、任务中心、AI 水印与内容编号、COS 转存(切 `/api/v1/tryon/*` 代理时一并做);
|
||||
5. 衣橱云端同步、多套搭配批量试穿、报告推荐搭配自动填充衣橱。
|
||||
|
||||
**文档版本**:v2.0 | 2026-09-02 | 作者:产品 Agent
|
||||
@@ -464,7 +464,25 @@ V1 无付费、无会员、无点数购买,**所有注册用户使用同一套
|
||||
2. **按设备维度计量**(设备指纹 + 匿名 ID),因此**必然可以被清数据绕过**。这是可接受的:游客额度极小,绕过的收益低于用户重装 App 的成本,不值得为它上强风控。
|
||||
3. **额度用尽必须是"引导登录"而非"静默失败"** —— 这是游客模式唯一的转化时机,做成一个报错就白费了。
|
||||
|
||||
**任务奖励额度的获取方式**(全部不涉及金钱):每日签到、完善画像资料、给报告结论打反馈、邀请好友、分享报告长图。
|
||||
**任务奖励额度的获取方式**(全部不涉及金钱)。V1 任务目录已于 **2026-09-11 定稿**(原为占位;研发任务书见《[开发任务包/T2b-任务换额度-端侧闭环](./开发任务包/T2b-任务换额度-端侧闭环.md)》):
|
||||
|
||||
| 任务 | task_key | 奖励 | 领取周期 | 完成判定 | 内测期状态 |
|
||||
|------|----------|------|---------|---------|-----------|
|
||||
| 每日签到 | `sign_in` | AI 对话 **+10 轮** | 每日 | 点击签到即完成(登录态 + 服务端日幂等),行为本身即任务 | ✅ 上 |
|
||||
| 给报告结论打反馈 | `report_feedback` | 发型/发色 **+2 次** | 每日 | **服务端可信**:当日 `rp_feedback` 存在本人记录 | ✅ 上 |
|
||||
| 分享我的报告 | `share_report` | 换装 **+3 次** | 每日 | **端侧自报**:调起系统分享面板即算完成,埋点观测 | ✅ 上(自报水位) |
|
||||
| 完成首次形象速测 | `first_report` | 报告重生成 **+1 次** | 终身一次 | **服务端可信**:`rp_report` 存在本人已完成报告 | ✅ 上(替代原「完善画像资料」) |
|
||||
| ~~邀请好友~~ | `invite_friend` | — | — | 无可信信号(无邀请码/回流链路) | ❌ **内测期下架**,V2 有邀请链路后重评 |
|
||||
|
||||
**数值决策理由(2026-09-11)**:
|
||||
|
||||
1. **与配额种子精确对齐**:四个任务的奖励合计恰好等于 `bl_quota_rule.task_bonus_max`(对话 10 / 换装 3 / 发型 2 / 重生成 1),只需改 `TASK_BONUS_RULES_JSON` 配置即生效,**不动配额种子、不发版**(`FR-BILL-04`)。
|
||||
2. **签到独得对话 +10**:Agent 单轮成本最低(≤¥0.05),签到让对话额度翻倍(10→20 轮/日)是最直接的日活钩子;物理上限 `hard_cap` 40 不变,§5.2.1 成本模型不受影响。
|
||||
3. **反馈奖发型 +2/日**(改掉原配置「反馈奖重生成 +1」):反馈是每日可重复行为,而重生成月封顶 1 次——原配置下用户当月第 2 天起再反馈必撞 40310,等于白做。且反馈数据是结论认同率/修正率指标(`FR-RPT-19`)的唯一数据来源,值得用高频日奖励驱动。
|
||||
4. **分享只能端侧自报,内测期接受**:Share Kit 系统分享无「实际发送成功」回调,内测期不做服务端事件校验(埋点本身可伪造且批上报有节流,做校验是自欺)。刷满收益被 `hard_cap` 8 锁死,已含在 §5.2.1 物理上限成本模型内,**零增量成本风险**;埋点观测滥用率,内测后复评。
|
||||
5. **邀请好友直接砍**:无邀请码与回流链路,任何形态都只能纯自报且无观测锚点,上了等于无条件每日 +1 换装。
|
||||
6. **「完善画像资料」改造为「完成首次形象速测」**:资料维护功能(`FR-ACC-06`)V1 未实现,无可信信号;完成速测是产品核心漏斗(报告是画像生产者),服务端有 `rp_report` 记录可校验,语义闭环「觉得不准,本月可重来一次」。
|
||||
7. 主表「报告重新生成 +1 次/月」在内测期由 `first_report` **一次性**兑现;月度循环获取途径等 V2 任务体系扩展时再补。
|
||||
|
||||
**三条不可违反的设计纪律**:
|
||||
|
||||
|
||||
@@ -1 +1,461 @@
|
||||
{"0":"HarmonyOS App Shell","1":"Product Architecture Docs","2":"Report Pipeline Stages","3":"Knowledge Base Admin","4":"Outfit Agent LLM","5":"Rules Engine CI","6":"Dual Login Sessions","7":"Aesthetic Report Spec","8":"Product Milestone Roadmap","9":"Local HarmonyOS Debug","10":"Start Icon Branding","11":"Self-shot Content Track","12":"AppScope Icon Foreground","13":"LLM Cost Circuit Breaker","14":"AI Content Compliance","15":"Entry Icon Background","16":"Entry Icon Foreground","17":"AppScope Icon Background","18":"SMS Dual Certificates","19":"Face Photo Retention","20":"Quota Guest Merge","21":"Analytics Event Ingest","22":"Weather Degrade Path","23":"Billing Reserve Commit","24":"Modular Monolith Domains","25":"Daily Cost Breaker","26":"Pose Body Measurement","27":"Golden Set Benchmark","28":"File Ownership Isolation","29":"Requirement Numbering Index","30":"Hvigor Build Config","31":"Hvigor Build Duplicate","32":"Cloud Credential Checklist","33":"Four Parallel Tracks"}
|
||||
{
|
||||
"0": "HomePage",
|
||||
"1": "美搭文档索引",
|
||||
"2": "ReportResultPage",
|
||||
"3": "Hybrid Retrieval Pipeline",
|
||||
"4": "Outfit Agent",
|
||||
"5": "Golden Set CI Framework",
|
||||
"6": "SMS Phone Login",
|
||||
"7": "Personal image report sample",
|
||||
"8": "Milestone M1 engineering foundation",
|
||||
"9": "美搭 HarmonyOS ArkTS App",
|
||||
"10": "HarmonyOS entry startIcon: blue rounded-square splash/launcher branding with a centered 2x2 pale-tile grid",
|
||||
"11": "Self-shot item pipeline",
|
||||
"12": "2x2 grid of four identical rounded squares",
|
||||
"13": "ensure_scene_allowed",
|
||||
"14": "AI content dual labeling",
|
||||
"15": "Solid vibrant blue HarmonyOS entry icon background",
|
||||
"16": "2x2 grid of rounded squares",
|
||||
"17": "HarmonyOS adaptive icon background layer",
|
||||
"18": "Independent dual login channels",
|
||||
"19": "Face original retention 2 hours",
|
||||
"20": "Product quota table section 3.2",
|
||||
"21": "Events Batch Ingest",
|
||||
"22": "Agent weather degrade path",
|
||||
"23": "Billing four-table boundary",
|
||||
"24": "Eleven business domain packages",
|
||||
"25": "Four-tier daily cost circuit breaker",
|
||||
"26": "TD-05 RTMPose self-hosted keypoints",
|
||||
"27": "Golden set four subsets 330",
|
||||
"28": "Coordinator Commit",
|
||||
"29": "FR NFR CR TD ADR T-P R numbering",
|
||||
"30": "entry/hvigorfile.ts",
|
||||
"31": "app/hvigorfile.ts",
|
||||
"32": "Cloud and credential pre-open checklist",
|
||||
"33": "Four parallel tracks BE FE ALG CONT",
|
||||
"34": "api/deps.py",
|
||||
"35": "test_report_validation.py",
|
||||
"36": "agent/index.ts",
|
||||
"37": "birefnet_matting.py",
|
||||
"38": "compile.ts",
|
||||
"39": "unit/test_retrieval_cache.py",
|
||||
"40": "BillingService",
|
||||
"41": "kimi-cli.ts",
|
||||
"42": "Usage",
|
||||
"43": "AppError",
|
||||
"44": "tts.ts",
|
||||
"45": "QuotaState",
|
||||
"46": "defaults.ts",
|
||||
"47": "retrieval/repo.py",
|
||||
"48": "render.ts",
|
||||
"49": "provider.ts",
|
||||
"50": "test_report_export.py",
|
||||
"51": "test_upload_presign.py",
|
||||
"52": "_client",
|
||||
"53": "runner.py",
|
||||
"54": "remotion",
|
||||
"55": "AutoVideo — 产品需求文档",
|
||||
"56": "PromptRegistry",
|
||||
"57": "export_tasks.py",
|
||||
"58": "kb/service.py",
|
||||
"59": "library/index.ts",
|
||||
"60": "test_hybrid_retrieval.py",
|
||||
"61": "axios/index.ts",
|
||||
"62": "test_gateway.py",
|
||||
"63": "作品集站点 PRD — 可深读的技术视频站",
|
||||
"64": "BlockOutputs.vue",
|
||||
"65": "MetaEditor.vue",
|
||||
"66": "PluginContext.hpp",
|
||||
"67": "受众画像先行法(受众驱动 · 写作主法)",
|
||||
"68": "TopBar.vue",
|
||||
"69": "ReportExportService",
|
||||
"70": "llm/__init__.py",
|
||||
"71": "test_deepseek_provider.py",
|
||||
"72": "store.ts",
|
||||
"73": "3. 详细任务分解",
|
||||
"74": "VideoComposition.tsx",
|
||||
"75": "properties",
|
||||
"76": "dependencies",
|
||||
"77": "test_report_flow.py",
|
||||
"78": "DeterministicEmbedder",
|
||||
"79": "parser/blocks.ts",
|
||||
"80": "script.ts",
|
||||
"81": "autovideo.ts",
|
||||
"82": "doctor.ts",
|
||||
"83": "validate.ts",
|
||||
"84": "test_rules_service.py",
|
||||
"85": "test_events_validation.py",
|
||||
"86": "reserve",
|
||||
"87": "SettingsModal.vue",
|
||||
"88": "ScriptEditor.vue",
|
||||
"89": "test_huawei_verifier.py",
|
||||
"90": "next_id",
|
||||
"91": "puton-master/src/pages/HomePage.vue",
|
||||
"92": "routes/blocks.ts",
|
||||
"93": "test_vertical_slice.py",
|
||||
"94": "llm.hpp",
|
||||
"95": "taskRunner.ts",
|
||||
"96": "BlockScriptEditor.vue",
|
||||
"97": "Settings",
|
||||
"98": "test_sms_login.py",
|
||||
"99": "InMemoryCostCounter",
|
||||
"100": "RateTable",
|
||||
"101": "types/api.ts",
|
||||
"102": "cosyvoice-tts/server.py",
|
||||
"103": "test_rules_dsl.py",
|
||||
"104": "ok",
|
||||
"105": "dependencies",
|
||||
"106": "devDependencies",
|
||||
"107": "test_tiered_budget.py",
|
||||
"108": "test_auth_sms.py",
|
||||
"109": "ProjectPage.vue",
|
||||
"110": "RetrievalService",
|
||||
"111": "RerankerBase",
|
||||
"112": "voxcpm-tts/server.py",
|
||||
"113": "diagnosis_stage.py",
|
||||
"114": "auth.py",
|
||||
"115": "rules.py",
|
||||
"116": "SmsStore",
|
||||
"117": "AssetManager.vue",
|
||||
"118": "真机闭环自动化测试 · 实践指南",
|
||||
"119": "kb/schemas.py",
|
||||
"120": ".ensure_scene_allowed",
|
||||
"121": "get",
|
||||
"122": "CircuitBreaker",
|
||||
"123": "build_scene_configs",
|
||||
"124": "assembly-gen.ts",
|
||||
"125": "quick.ts",
|
||||
"126": "KbEntity",
|
||||
"127": "test_report_vision.py",
|
||||
"128": "devDependencies",
|
||||
"129": "LocalLlm",
|
||||
"130": "napi_init.cpp",
|
||||
"131": "LlmContext",
|
||||
"132": "What You Must Do When Invoked",
|
||||
"133": "AutoVideo 视频构建指南",
|
||||
"134": "renderer.py",
|
||||
"135": "BlockSidebar.vue",
|
||||
"136": "image-gen.ts",
|
||||
"137": "billing/quota.py",
|
||||
"138": "compilerOptions",
|
||||
"139": "test_quota_domain.py",
|
||||
"140": "RuleContext",
|
||||
"141": "test_rules_golden.py",
|
||||
"142": "Executor.hpp",
|
||||
"143": "HalideRuntime.h",
|
||||
"144": "guidelines 方法论评审",
|
||||
"145": "visual-registry.ts",
|
||||
"146": "web/src/pages/HomePage.vue",
|
||||
"147": "resolveClaudeCredentials",
|
||||
"148": "dependencies",
|
||||
"149": "TaskQueue",
|
||||
"150": "grant_bonus",
|
||||
"151": "PickAndUploadImage.vue",
|
||||
"152": "Scope",
|
||||
"153": "render/qa.ts",
|
||||
"154": "CodexCliDriver",
|
||||
"155": "RedisSmsStore",
|
||||
"156": "3. Phase A 技术约束下的产品裁决",
|
||||
"157": "admin/routers/kb.py",
|
||||
"158": "system.ts",
|
||||
"159": "visual-metrics.ts",
|
||||
"160": "AutoVideo/start.sh",
|
||||
"161": "dict.md",
|
||||
"162": "kimi-cli.test.ts",
|
||||
"163": "ids.py",
|
||||
"164": "integration/test_kb_entity.py",
|
||||
"165": "test_admin_console.py",
|
||||
"166": "AutoVideo 输入资源编写指南",
|
||||
"167": "compilerOptions",
|
||||
"168": "test_task_bonus_rules.py",
|
||||
"169": "2. 逐轮还原(决策链与证据)",
|
||||
"170": "sms_login.py",
|
||||
"171": "TencentSmsSender",
|
||||
"172": "AutoVideo Web UI — 部署指南",
|
||||
"173": "properties",
|
||||
"174": "visuals.ts",
|
||||
"175": "MuseTalk 口型同步服务 — 使用说明",
|
||||
"176": "identity/service.py",
|
||||
"177": "download_models.py",
|
||||
"178": "ClothesBox.vue",
|
||||
"179": "properties",
|
||||
"180": "visuals.test.ts",
|
||||
"181": "compilerOptions",
|
||||
"182": "identity/repo.py",
|
||||
"184": "test_task_bonus.py",
|
||||
"185": "compilerOptions",
|
||||
"186": "AutoVideo/README.md",
|
||||
"187": "dict.global.md",
|
||||
"188": "任务表",
|
||||
"190": "meta.ts",
|
||||
"191": "MuseTalk 服务部署任务清单",
|
||||
"192": "BlockPanel.vue",
|
||||
"193": "试衣间与发型设计 · 产品与技术方案",
|
||||
"194": "KbRule",
|
||||
"195": "DropdownMenu.vue",
|
||||
"196": "compilerOptions",
|
||||
"197": "Interpreter.hpp",
|
||||
"198": "IdentityService",
|
||||
"199": "record_gap",
|
||||
"200": "test_golden.py",
|
||||
"202": "Tensor.hpp",
|
||||
"203": "客户端开发交接文档(新机器)",
|
||||
"204": "T9 · 视觉算法 SPIKE 报告(RTMPose 体型原型 + BiRefNet 抠图)",
|
||||
"205": "test_auth_refresh.py",
|
||||
"207": "test_report_stages.py",
|
||||
"208": "AutoVideo Web UI — 产品需求文档",
|
||||
"209": "visual-review.ts",
|
||||
"210": "AutoVideo/src/remotion/slides/CameraTransform.tsx",
|
||||
"211": "tsconfig.server.json",
|
||||
"212": "AssetUploadDialog.vue",
|
||||
"213": "test_log_redaction.py",
|
||||
"215": "scripts",
|
||||
"216": "Rect.h",
|
||||
"217": "4. 功能模块",
|
||||
"218": "properties",
|
||||
"219": "required",
|
||||
"220": "models.py",
|
||||
"221": "auto-import.d.ts",
|
||||
"222": "test_events.py",
|
||||
"223": "GenderSelect.vue",
|
||||
"224": "install.sh script",
|
||||
"225": "audio",
|
||||
"226": "enum",
|
||||
"227": "required",
|
||||
"228": "T18 · 端侧小模型 SPIKE·MNN + Qwen3-0.6B 真机可行性与意图分类原型",
|
||||
"229": "美搭 —— 试衣间 v2 / 发型页 v2 / 美学诊断 UI 设计规范",
|
||||
"232": "HistoryScroll.vue",
|
||||
"233": "HTML 视觉模式 PRD(@visual: html)",
|
||||
"234": "AutoVideo",
|
||||
"235": "script.schema.json",
|
||||
"236": "properties",
|
||||
"237": "compliance/service.py",
|
||||
"238": "EntityCreate",
|
||||
"239": "黄金集回归框架(Golden Set)",
|
||||
"240": "Menu.vue",
|
||||
"242": "AutoVideo Agent 总入口",
|
||||
"243": "3.1 动画模式(默认)写法要点",
|
||||
"244": "properties",
|
||||
"245": "render",
|
||||
"247": "KbService",
|
||||
"248": "ModelItemCard.vue",
|
||||
"249": "interface.ts",
|
||||
"250": "graphify reference: extra exports and benchmark",
|
||||
"251": "AutoVideo — Claude Code 项目指令",
|
||||
"252": "附录 A — CLI 模块改造清单(必须先完成)",
|
||||
"253": "AutoVideo 开发任务清单",
|
||||
"254": "阶段 WP5:视觉模式 / 文生图 / 设置面板(对应 @WEB_PRD.md Phase 5)",
|
||||
"255": "AutoVideo/package.json",
|
||||
"256": "scripts",
|
||||
"257": "visualMode",
|
||||
"258": "AutoVideo/stop.sh",
|
||||
"259": "My Video",
|
||||
"261": "test_report_dimensions.py",
|
||||
"262": "InMemorySmsStore",
|
||||
"263": "puton",
|
||||
"264": "History.vue",
|
||||
"265": "6. 管线集成(逐文件改动)",
|
||||
"266": "A.5 图片模式:完整改动清单",
|
||||
"267": "阶段 6:render stage",
|
||||
"268": "阶段 WP2:脚本编辑与资源管理(对应 @WEB_PRD.md Phase 2)",
|
||||
"269": "阶段 WP6:全量构建与部署收尾(对应 @WEB_PRD.md Phase 6)",
|
||||
"270": "3. 视觉描述(visuals.md 块体)",
|
||||
"271": "narration",
|
||||
"272": "sandbox.ts",
|
||||
"274": "Fun-CosyVoice3-0.5B — TTS 语音合成服务",
|
||||
"275": "MuseTalk — 口型/唇形同步服务",
|
||||
"276": "env.py",
|
||||
"277": "Four post-filing ongoing duties",
|
||||
"278": "`packages/llm/` — LLM 网关",
|
||||
"279": "conftest.py",
|
||||
"280": "11. 开发阶段划分",
|
||||
"281": "5. API 设计",
|
||||
"282": "附录 C — Phase 验收脚本",
|
||||
"283": "阶段 WP1:基础骨架(对应 @WEB_PRD.md Phase 1)",
|
||||
"284": "阶段 WP3:任务队列与进度(对应 @WEB_PRD.md Phase 3)",
|
||||
"285": "SenseNova-U1 — 文生图服务",
|
||||
"286": "VoxCPM2 — TTS 语音合成服务",
|
||||
"287": "TotalInternalReflection.tsx",
|
||||
"288": "VisionProvider",
|
||||
"289": "`packages/billing/` — 配额与计量(V1)",
|
||||
"290": "`packages/compliance/` — 统一合规出口",
|
||||
"291": "`packages/domain/` — 纯领域逻辑",
|
||||
"292": "`packages/task/` — 统一任务中心",
|
||||
"293": ".prettierrc.json",
|
||||
"294": "TheWelcome.vue",
|
||||
"295": "SingleCheckbox.vue",
|
||||
"296": "graphify reference: query, path, explain",
|
||||
"297": "13. 分阶段实施计划",
|
||||
"298": "1. 背景与目标",
|
||||
"299": "4. 架构决策(技术选型)",
|
||||
"300": "13. 设置与配置面板",
|
||||
"301": "阶段 1:compile(无外部依赖,最先做)",
|
||||
"302": "阶段 3:tts stage(需要 VoxCPM 服务)",
|
||||
"303": "阶段 4:visuals stage(需要 Claude API)",
|
||||
"304": "AutoVideo Web UI — 开发任务清单",
|
||||
"305": "阶段 WP4:产物预览(对应 @WEB_PRD.md Phase 4)",
|
||||
"306": "4. `narration.md` — 旁白",
|
||||
"307": "AccentOverride",
|
||||
"308": "comp.tsx",
|
||||
"309": "`apps/` — 可部署的进程",
|
||||
"310": "`packages/agent/` — 穿搭 Agent",
|
||||
"312": "`packages/kb/` — 美学知识库",
|
||||
"313": "`packages/shared/` — 横切基础设施",
|
||||
"314": "`prompts/` — 版本化 Prompt 模板",
|
||||
"315": "meida_backend · 美搭服务端",
|
||||
"316": "`tests/`",
|
||||
"317": "MyButton.vue",
|
||||
"318": "MyIcons.vue",
|
||||
"319": "getClothes",
|
||||
"320": "10. 编码一致性(partial mp4 格式对齐)",
|
||||
"321": "11. 入退场动画与字幕层(框架注入层)",
|
||||
"322": "12. Web UI 与配置",
|
||||
"323": "2. 用户故事",
|
||||
"324": "3. 语法设计",
|
||||
"325": "5. seek-hook 约定(动画档)",
|
||||
"326": "3. 项目结构约定",
|
||||
"327": "4.5 任务队列与进度",
|
||||
"328": "给 AI agent 的执行规则",
|
||||
"329": "阶段 5:Remotion 渲染层",
|
||||
"330": "阶段 9:测试 / 文档 / 安装脚本",
|
||||
"331": "TitleCards.tsx",
|
||||
"332": "sensenova-t2i/start.sh",
|
||||
"333": "TD-28 V1 free quota commercialization",
|
||||
"334": "Q: Why does ReportCreatePage connect HarmonyOS App Shell to Report Pipeline Stages?",
|
||||
"335": "Q: How do the four Stage Protocol stages fill DimensionResult?",
|
||||
"337": "`deploy/`",
|
||||
"338": "`packages/identity/` — 账号与会话",
|
||||
"339": "`packages/memory/` — 记忆系统(占位)",
|
||||
"340": "`packages/` — 业务域与能力包",
|
||||
"341": "smoke.sh",
|
||||
"342": "`services/` — 独立部署的 AI 能力服务",
|
||||
"343": "`services/vision/` — 视觉服务(占位)",
|
||||
"344": "FakeClock",
|
||||
"345": "VpShopLinkToGetPictures.vue",
|
||||
"346": "serve-models.mjs",
|
||||
"347": "graphify reference: add a URL and watch a folder",
|
||||
"348": "graphify reference: commit hook and native AGENTS.md integration",
|
||||
"349": "graphify reference: incremental update and cluster-only",
|
||||
"350": "7. 资产处理",
|
||||
"351": "8. 安全沙箱",
|
||||
"352": "14. 部署脚本(v1 必交付)",
|
||||
"353": "AutoVideo 开发进度",
|
||||
"354": "阶段 0:项目初始化",
|
||||
"355": "阶段 8:build / doctor / init",
|
||||
"356": "AutoVideo — MuseTalk 口型同步功能开发进度",
|
||||
"357": "ShaderCodeSlide.tsx",
|
||||
"358": "third_servers/stop.sh",
|
||||
"359": "web/src/remotion/slides/CameraTransform.tsx",
|
||||
"360": "COS Presigned Upload",
|
||||
"361": "`migrations/` — Alembic",
|
||||
"362": "0003_align_quota_with_product_ssot.py",
|
||||
"363": "`packages/notify/` — 通知(占位)",
|
||||
"364": "`packages/profile/` — 画像与记忆(占位)",
|
||||
"365": "`packages/report/` — 美学报告(占位)",
|
||||
"366": "`packages/retrieval/` — 向量与混合检索",
|
||||
"367": "`packages/rules` — 规则引擎(TD-10 自研数据驱动决策表)",
|
||||
"368": "`packages/tryon/` — 智能试穿与发型(占位)",
|
||||
"369": "`scripts/`",
|
||||
"370": "`services/matting/` — 抠图批处理(占位)",
|
||||
"371": "ClothesPool.vue",
|
||||
"372": "i18n/index.ts",
|
||||
"373": "getHistory",
|
||||
"374": "graphify reference: GitHub clone and cross-repo merge",
|
||||
"375": "graphify reference: transcribe video and audio",
|
||||
"376": "9. 数据流说明",
|
||||
"377": "SubtitleOverlay.test.tsx",
|
||||
"378": "0001_initial_foundation.py",
|
||||
"379": "0002_ai_call_reasoning_and_rate_tier.py",
|
||||
"380": "0004_sessions_and_events.py",
|
||||
"381": "0005_kb_tables.py",
|
||||
"382": "0006_report_tables.py",
|
||||
"383": "puton-master/env.d.ts",
|
||||
"384": "puton-master/src/main.ts",
|
||||
"385": "getModels",
|
||||
"386": "puton-master/tsconfig.json",
|
||||
"387": "manifest.json",
|
||||
"388": "AutoVideo/AGENTS.md",
|
||||
"389": "extraction-spec.md",
|
||||
"390": "@babel/parser",
|
||||
"391": "ms",
|
||||
"392": "pinyin-pro",
|
||||
"393": "puppeteer-core",
|
||||
"394": "B01/Component.tsx",
|
||||
"395": "B02/Component.tsx",
|
||||
"396": "cosyvoice-tts/install.sh",
|
||||
"397": "cosyvoice-tts/start.sh",
|
||||
"398": "musetalk-lipsync/start.sh",
|
||||
"399": "voxcpm-tts/start.sh",
|
||||
"400": "web/env.d.ts",
|
||||
"401": "@element-plus/icons-vue",
|
||||
"402": "eslint-plugin-import",
|
||||
"403": "admin/__init__.py",
|
||||
"404": "api/__init__.py",
|
||||
"405": "apps/__init__.py",
|
||||
"406": "agent/__init__.py",
|
||||
"407": "analytics/__init__.py",
|
||||
"408": "compliance/__init__.py",
|
||||
"409": "domain/__init__.py",
|
||||
"410": "domain/task/__init__.py",
|
||||
"411": "packages/__init__.py",
|
||||
"412": "providers/__init__.py",
|
||||
"413": "memory/__init__.py",
|
||||
"414": "notify/__init__.py",
|
||||
"415": "profile/__init__.py",
|
||||
"416": "shared/__init__.py",
|
||||
"417": "storage/__init__.py",
|
||||
"418": "packages/tryon/__init__.py",
|
||||
"419": "wardrobe/__init__.py",
|
||||
"420": "wardrobe/README.md",
|
||||
"421": "services/__init__.py",
|
||||
"422": "matting/__init__.py",
|
||||
"423": "services/tryon/__init__.py",
|
||||
"424": "services/tryon/README.md",
|
||||
"425": "vision/__init__.py",
|
||||
"426": "swiper",
|
||||
"427": "@types/axios",
|
||||
"428": "vue-router",
|
||||
"429": "@vueuse/integrations",
|
||||
"430": "@rushstack/eslint-patch",
|
||||
"431": "@tsconfig/node20",
|
||||
"432": "typescript",
|
||||
"433": "unplugin-vue-components",
|
||||
"434": "@vitejs/plugin-vue",
|
||||
"435": "vue-tsc",
|
||||
"436": "ScrollWithBar.vue",
|
||||
"437": "constants/index.ts",
|
||||
"438": "downloadFile",
|
||||
"439": "puton-master/src/router/index.ts",
|
||||
"440": "counter.ts",
|
||||
"441": "globalStore.ts",
|
||||
"442": "utils/index.ts",
|
||||
"443": "puton-master/vite.config.ts",
|
||||
"444": "index.d.ts",
|
||||
"445": "remotion.config.ts",
|
||||
"446": "starter/meta.md",
|
||||
"447": "narration.md",
|
||||
"448": "visuals.md",
|
||||
"449": "block1.md",
|
||||
"450": "block2.md",
|
||||
"451": "compile-test/meta.md",
|
||||
"452": "web/vite.config.ts",
|
||||
"453": "analytics/README.md",
|
||||
"454": "storage/README.md",
|
||||
"455": "tests/__init__.py",
|
||||
"456": "integration/__init__.py",
|
||||
"457": "unit/__init__.py",
|
||||
"458": "meida-backend",
|
||||
"459": ".eslintrc.cjs",
|
||||
"460": "IconAdd.vue",
|
||||
"461": "IconDelete.vue",
|
||||
"462": "IconDrag.vue",
|
||||
"463": "IconEdit.vue",
|
||||
"464": "IconLink.vue",
|
||||
"465": "IconSearch.vue",
|
||||
"466": "IconUpload.vue",
|
||||
"467": "MyCard.vue",
|
||||
"468": "SearchInput.vue",
|
||||
"469": "service/api.ts",
|
||||
"470": "vue-router",
|
||||
"471": "vue"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
{
|
||||
"0": "HomePage",
|
||||
"1": "美搭文档索引",
|
||||
"2": "ReportResultPage",
|
||||
"3": "Hybrid Retrieval Pipeline",
|
||||
"4": "Outfit Agent",
|
||||
"5": "Golden Set CI Framework",
|
||||
"6": "SMS Phone Login",
|
||||
"7": "Personal image report sample",
|
||||
"8": "Milestone M1 engineering foundation",
|
||||
"9": "美搭 HarmonyOS ArkTS App",
|
||||
"10": "HarmonyOS entry startIcon: blue rounded-square splash/launcher branding with a centered 2x2 pale-tile grid",
|
||||
"11": "Self-shot item pipeline",
|
||||
"12": "2x2 grid of four identical rounded squares",
|
||||
"13": "ensure_scene_allowed",
|
||||
"14": "AI content dual labeling",
|
||||
"15": "Solid vibrant blue HarmonyOS entry icon background",
|
||||
"16": "2x2 grid of rounded squares",
|
||||
"17": "HarmonyOS adaptive icon background layer",
|
||||
"18": "Independent dual login channels",
|
||||
"19": "Face original retention 2 hours",
|
||||
"20": "Product quota table section 3.2",
|
||||
"21": "Events Batch Ingest",
|
||||
"22": "Agent weather degrade path",
|
||||
"23": "Billing four-table boundary",
|
||||
"24": "Eleven business domain packages",
|
||||
"25": "Four-tier daily cost circuit breaker",
|
||||
"26": "TD-05 RTMPose self-hosted keypoints",
|
||||
"27": "Golden set four subsets 330",
|
||||
"28": "Coordinator Commit",
|
||||
"29": "FR NFR CR TD ADR T-P R numbering",
|
||||
"30": "entry/hvigorfile.ts",
|
||||
"31": "app/hvigorfile.ts",
|
||||
"32": "Cloud and credential pre-open checklist",
|
||||
"33": "Four parallel tracks BE FE ALG CONT",
|
||||
"34": "api/deps.py",
|
||||
"35": "test_report_validation.py",
|
||||
"36": "agent/index.ts",
|
||||
"37": "birefnet_matting.py",
|
||||
"38": "compile.ts",
|
||||
"39": "unit/test_retrieval_cache.py",
|
||||
"40": "pipeline.py",
|
||||
"41": "gateway.py",
|
||||
"42": "Usage",
|
||||
"43": "shared/errors.py",
|
||||
"44": "quick.ts",
|
||||
"45": "QuotaState",
|
||||
"46": "defaults.ts",
|
||||
"47": "retrieval/repo.py",
|
||||
"48": "render.ts",
|
||||
"49": "provider.ts",
|
||||
"50": "test_report_export.py",
|
||||
"51": "test_upload_presign.py",
|
||||
"52": "test_admin_app.py",
|
||||
"53": "runner.py",
|
||||
"54": "remotion",
|
||||
"55": "AutoVideo — 产品需求文档",
|
||||
"56": "PromptRegistry",
|
||||
"57": "config.py",
|
||||
"58": "unit/test_kb_entity.py",
|
||||
"59": "library/index.ts",
|
||||
"60": "next_id",
|
||||
"61": "axios/index.ts",
|
||||
"62": "test_gateway.py",
|
||||
"63": "作品集站点 PRD — 可深读的技术视频站",
|
||||
"64": "BlockOutputs.vue",
|
||||
"65": "MetaEditor.vue",
|
||||
"66": "PluginContext.hpp",
|
||||
"67": "受众画像先行法(受众驱动 · 写作主法)",
|
||||
"68": "TopBar.vue",
|
||||
"69": "AppError",
|
||||
"70": "llm/errors.py",
|
||||
"71": "test_deepseek_provider.py",
|
||||
"72": "store.ts",
|
||||
"73": "3. 详细任务分解",
|
||||
"74": "VideoComposition.tsx",
|
||||
"75": "properties",
|
||||
"76": "dependencies",
|
||||
"77": "test_report_flow.py",
|
||||
"78": "DeterministicEmbedder",
|
||||
"79": "parser/blocks.ts",
|
||||
"80": "script.ts",
|
||||
"81": "autovideo.ts",
|
||||
"82": "doctor.ts",
|
||||
"83": "validate.ts",
|
||||
"84": "reports.py",
|
||||
"85": "analytics/service.py",
|
||||
"86": "TaskStatus",
|
||||
"87": "SettingsModal.vue",
|
||||
"88": "ScriptEditor.vue",
|
||||
"89": "test_huawei_verifier.py",
|
||||
"90": "RpReport",
|
||||
"91": "puton-master/src/pages/HomePage.vue",
|
||||
"92": "routes/blocks.ts",
|
||||
"93": "get_settings",
|
||||
"94": "llm.hpp",
|
||||
"95": "taskRunner.ts",
|
||||
"96": "BlockScriptEditor.vue",
|
||||
"97": "app.py",
|
||||
"98": "test_sms_login.py",
|
||||
"99": "InMemoryCostCounter",
|
||||
"100": "ReportContext",
|
||||
"101": "TaskBar.vue",
|
||||
"102": "cosyvoice-tts/server.py",
|
||||
"103": "test_rules_dsl.py",
|
||||
"104": "routers/quota.py",
|
||||
"105": "dependencies",
|
||||
"106": "devDependencies",
|
||||
"107": "test_tiered_budget.py",
|
||||
"108": "test_auth_sms.py",
|
||||
"109": "ProjectPage.vue",
|
||||
"110": "RetrievalService",
|
||||
"111": "RerankerBase",
|
||||
"112": "voxcpm-tts/server.py",
|
||||
"113": "diagnosis_stage.py",
|
||||
"114": "auth.py",
|
||||
"115": "rules.py",
|
||||
"116": "sms_login.py",
|
||||
"117": "AssetManager.vue",
|
||||
"118": "真机闭环自动化测试 · 实践指南",
|
||||
"119": "kb/schemas.py",
|
||||
"120": "Task",
|
||||
"121": "ok",
|
||||
"122": "CircuitBreaker",
|
||||
"123": "types/api.ts",
|
||||
"124": "assembly-gen.ts",
|
||||
"125": "quick.test.ts",
|
||||
"126": "kb/repo.py",
|
||||
"127": "test_report_vision.py",
|
||||
"128": "devDependencies",
|
||||
"129": "LocalLlm",
|
||||
"130": "napi_init.cpp",
|
||||
"131": "LlmContext",
|
||||
"132": "What You Must Do When Invoked",
|
||||
"133": "AutoVideo 视频构建指南",
|
||||
"134": "projectService.ts",
|
||||
"135": "BlockSidebar.vue",
|
||||
"136": "image-gen.ts",
|
||||
"137": "tts/qa.ts",
|
||||
"138": "compilerOptions",
|
||||
"139": "test_report_diagnosis.py",
|
||||
"140": "RuleContext",
|
||||
"141": "test_rules_golden.py",
|
||||
"142": "Executor.hpp",
|
||||
"143": "HalideRuntime.h",
|
||||
"144": "guidelines 方法论评审",
|
||||
"145": "visual-registry.ts",
|
||||
"146": "web/src/pages/HomePage.vue",
|
||||
"147": "test_report_copy_stage.py",
|
||||
"148": "dependencies",
|
||||
"149": "TaskQueue",
|
||||
"150": "utils/api.ts",
|
||||
"151": "PickAndUploadImage.vue",
|
||||
"152": "Scope",
|
||||
"153": "render/qa.ts",
|
||||
"154": "dsl.py",
|
||||
"155": "HuaweiIdTokenVerifier",
|
||||
"156": "3. Phase A 技术约束下的产品裁决",
|
||||
"157": "admin/routers/kb.py",
|
||||
"158": "system.ts",
|
||||
"159": "visual-metrics.ts",
|
||||
"160": "AutoVideo/start.sh",
|
||||
"161": "dict.md",
|
||||
"162": "auth_sms.py",
|
||||
"163": "ids.py",
|
||||
"164": "integration/test_kb_entity.py",
|
||||
"165": "test_admin_console.py",
|
||||
"166": "AutoVideo 输入资源编写指南",
|
||||
"167": "compilerOptions",
|
||||
"168": "Settings",
|
||||
"169": "2. 逐轮还原(决策链与证据)",
|
||||
"170": "normalize_phone",
|
||||
"171": "TencentSmsSender",
|
||||
"172": "AutoVideo Web UI — 部署指南",
|
||||
"173": "properties",
|
||||
"174": "visuals.ts",
|
||||
"175": "MuseTalk 口型同步服务 — 使用说明",
|
||||
"176": "identity/service.py",
|
||||
"177": "download_models.py",
|
||||
"178": "ClothesBox.vue",
|
||||
"179": "properties",
|
||||
"180": "visuals.test.ts",
|
||||
"181": "compilerOptions",
|
||||
"182": "identity/repo.py",
|
||||
"183": "test_response_envelope.py",
|
||||
"184": "test_task_bonus.py",
|
||||
"185": "compilerOptions",
|
||||
"186": "AutoVideo/README.md",
|
||||
"187": "dict.global.md",
|
||||
"188": "任务表",
|
||||
"189": "routes/assets.ts",
|
||||
"190": "meta.ts",
|
||||
"191": "MuseTalk 服务部署任务清单",
|
||||
"192": "BlockPanel.vue",
|
||||
"193": "试衣间与发型设计 · 产品与技术方案",
|
||||
"194": "KbRule",
|
||||
"195": "DropdownMenu.vue",
|
||||
"196": "compilerOptions",
|
||||
"197": "ScheduleConfig",
|
||||
"198": "IdentityService",
|
||||
"199": "record_gap",
|
||||
"200": "db.py",
|
||||
"201": "test_auth_huawei.py",
|
||||
"202": "Tensor.hpp",
|
||||
"203": "客户端开发交接文档(新机器)",
|
||||
"204": "T9 · 视觉算法 SPIKE 报告(RTMPose 体型原型 + BiRefNet 抠图)",
|
||||
"205": "test_auth_refresh.py",
|
||||
"206": "halide_buffer_t",
|
||||
"207": "test_report_stages.py",
|
||||
"208": "AutoVideo Web UI — 产品需求文档",
|
||||
"209": "visual-review.ts",
|
||||
"210": "AutoVideo/src/remotion/slides/CameraTransform.tsx",
|
||||
"211": "tsconfig.server.json",
|
||||
"212": "AssetUploadDialog.vue",
|
||||
"213": "test_log_redaction.py",
|
||||
"214": "test_rules_engine.py",
|
||||
"215": "scripts",
|
||||
"216": "Rect.h",
|
||||
"217": "4. 功能模块",
|
||||
"218": "properties",
|
||||
"219": "required",
|
||||
"220": "models.py",
|
||||
"221": "auto-import.d.ts",
|
||||
"222": "test_events.py",
|
||||
"223": "GenderSelect.vue",
|
||||
"224": "install.sh script",
|
||||
"225": "audio",
|
||||
"226": "enum",
|
||||
"227": "required",
|
||||
"228": "T18 · 端侧小模型 SPIKE·MNN + Qwen3-0.6B 真机可行性与意图分类原型",
|
||||
"229": "美搭 —— 试衣间 v2 / 发型页 v2 / 美学诊断 UI 设计规范",
|
||||
"230": "engine.py",
|
||||
"231": "vision.py",
|
||||
"232": "HistoryScroll.vue",
|
||||
"233": "HTML 视觉模式 PRD(@visual: html)",
|
||||
"234": "AutoVideo",
|
||||
"235": "script.schema.json",
|
||||
"236": "properties",
|
||||
"237": "compliance/service.py",
|
||||
"238": "EntityCreate",
|
||||
"239": "黄金集回归框架(Golden Set)",
|
||||
"240": "Menu.vue",
|
||||
"241": "events.py",
|
||||
"242": "AutoVideo Agent 总入口",
|
||||
"243": "3.1 动画模式(默认)写法要点",
|
||||
"244": "properties",
|
||||
"245": "render",
|
||||
"246": "output.ts",
|
||||
"247": "KbAsset",
|
||||
"248": "ModelItemCard.vue",
|
||||
"249": "interface.ts",
|
||||
"250": "graphify reference: extra exports and benchmark",
|
||||
"251": "AutoVideo — Claude Code 项目指令",
|
||||
"252": "附录 A — CLI 模块改造清单(必须先完成)",
|
||||
"253": "AutoVideo 开发任务清单",
|
||||
"254": "阶段 WP5:视觉模式 / 文生图 / 设置面板(对应 @WEB_PRD.md Phase 5)",
|
||||
"255": "AutoVideo/package.json",
|
||||
"256": "scripts",
|
||||
"257": "visualMode",
|
||||
"258": "AutoVideo/stop.sh",
|
||||
"259": "My Video",
|
||||
"260": "rules/repo.py",
|
||||
"261": "test_report_dimensions.py",
|
||||
"262": "InMemorySmsStore",
|
||||
"263": "puton",
|
||||
"264": "History.vue",
|
||||
"265": "6. 管线集成(逐文件改动)",
|
||||
"266": "A.5 图片模式:完整改动清单",
|
||||
"267": "阶段 6:render stage",
|
||||
"268": "阶段 WP2:脚本编辑与资源管理(对应 @WEB_PRD.md Phase 2)",
|
||||
"269": "阶段 WP6:全量构建与部署收尾(对应 @WEB_PRD.md Phase 6)",
|
||||
"270": "3. 视觉描述(visuals.md 块体)",
|
||||
"271": "narration",
|
||||
"272": "sandbox.ts",
|
||||
"273": "slugify",
|
||||
"274": "Fun-CosyVoice3-0.5B — TTS 语音合成服务",
|
||||
"275": "MuseTalk — 口型/唇形同步服务",
|
||||
"276": "env.py",
|
||||
"277": "Four post-filing ongoing duties",
|
||||
"278": "`packages/llm/` — LLM 网关",
|
||||
"279": "conftest.py",
|
||||
"280": "11. 开发阶段划分",
|
||||
"281": "5. API 设计",
|
||||
"282": "附录 C — Phase 验收脚本",
|
||||
"283": "阶段 WP1:基础骨架(对应 @WEB_PRD.md Phase 1)",
|
||||
"284": "阶段 WP3:任务队列与进度(对应 @WEB_PRD.md Phase 3)",
|
||||
"285": "SenseNova-U1 — 文生图服务",
|
||||
"286": "VoxCPM2 — TTS 语音合成服务",
|
||||
"287": "TotalInternalReflection.tsx",
|
||||
"288": "VisionProvider",
|
||||
"289": "`packages/billing/` — 配额与计量(V1)",
|
||||
"290": "`packages/compliance/` — 统一合规出口",
|
||||
"291": "`packages/domain/` — 纯领域逻辑",
|
||||
"292": "`packages/task/` — 统一任务中心",
|
||||
"293": ".prettierrc.json",
|
||||
"294": "TheWelcome.vue",
|
||||
"295": "SingleCheckbox.vue",
|
||||
"296": "graphify reference: query, path, explain",
|
||||
"297": "13. 分阶段实施计划",
|
||||
"298": "1. 背景与目标",
|
||||
"299": "4. 架构决策(技术选型)",
|
||||
"300": "13. 设置与配置面板",
|
||||
"301": "阶段 1:compile(无外部依赖,最先做)",
|
||||
"302": "阶段 3:tts stage(需要 VoxCPM 服务)",
|
||||
"303": "阶段 4:visuals stage(需要 Claude API)",
|
||||
"304": "AutoVideo Web UI — 开发任务清单",
|
||||
"305": "阶段 WP4:产物预览(对应 @WEB_PRD.md Phase 4)",
|
||||
"306": "4. `narration.md` — 旁白",
|
||||
"307": "AccentOverride",
|
||||
"308": "comp.tsx",
|
||||
"309": "`apps/` — 可部署的进程",
|
||||
"310": "`packages/agent/` — 穿搭 Agent",
|
||||
"311": "AgentService",
|
||||
"312": "`packages/kb/` — 美学知识库",
|
||||
"313": "`packages/shared/` — 横切基础设施",
|
||||
"314": "`prompts/` — 版本化 Prompt 模板",
|
||||
"315": "meida_backend · 美搭服务端",
|
||||
"316": "`tests/`",
|
||||
"317": "MyButton.vue",
|
||||
"318": "MyIcons.vue",
|
||||
"319": "getClothes",
|
||||
"320": "10. 编码一致性(partial mp4 格式对齐)",
|
||||
"321": "11. 入退场动画与字幕层(框架注入层)",
|
||||
"322": "12. Web UI 与配置",
|
||||
"323": "2. 用户故事",
|
||||
"324": "3. 语法设计",
|
||||
"325": "5. seek-hook 约定(动画档)",
|
||||
"326": "3. 项目结构约定",
|
||||
"327": "4.5 任务队列与进度",
|
||||
"328": "给 AI agent 的执行规则",
|
||||
"329": "阶段 5:Remotion 渲染层",
|
||||
"330": "阶段 9:测试 / 文档 / 安装脚本",
|
||||
"331": "TitleCards.tsx",
|
||||
"332": "sensenova-t2i/start.sh",
|
||||
"333": "TD-28 V1 free quota commercialization",
|
||||
"334": "Q: Why does ReportCreatePage connect HarmonyOS App Shell to Report Pipeline Stages?",
|
||||
"335": "Q: How do the four Stage Protocol stages fill DimensionResult?",
|
||||
"336": "admin_auth",
|
||||
"337": "`deploy/`",
|
||||
"338": "`packages/identity/` — 账号与会话",
|
||||
"339": "`packages/memory/` — 记忆系统(占位)",
|
||||
"340": "`packages/` — 业务域与能力包",
|
||||
"341": "smoke.sh",
|
||||
"342": "`services/` — 独立部署的 AI 能力服务",
|
||||
"343": "`services/vision/` — 视觉服务(占位)",
|
||||
"344": "FakeClock",
|
||||
"345": "VpShopLinkToGetPictures.vue",
|
||||
"346": "serve-models.mjs",
|
||||
"347": "graphify reference: add a URL and watch a folder",
|
||||
"348": "graphify reference: commit hook and native AGENTS.md integration",
|
||||
"349": "graphify reference: incremental update and cluster-only",
|
||||
"350": "7. 资产处理",
|
||||
"351": "8. 安全沙箱",
|
||||
"352": "14. 部署脚本(v1 必交付)",
|
||||
"353": "AutoVideo 开发进度",
|
||||
"354": "阶段 0:项目初始化",
|
||||
"355": "阶段 8:build / doctor / init",
|
||||
"356": "AutoVideo — MuseTalk 口型同步功能开发进度",
|
||||
"357": "ShaderCodeSlide.tsx",
|
||||
"358": "third_servers/stop.sh",
|
||||
"359": "web/src/remotion/slides/CameraTransform.tsx",
|
||||
"360": "COS Presigned Upload",
|
||||
"361": "`migrations/` — Alembic",
|
||||
"362": "0003_align_quota_with_product_ssot.py",
|
||||
"363": "`packages/notify/` — 通知(占位)",
|
||||
"364": "`packages/profile/` — 画像与记忆(占位)",
|
||||
"365": "`packages/report/` — 美学报告(占位)",
|
||||
"366": "`packages/retrieval/` — 向量与混合检索",
|
||||
"367": "`packages/rules` — 规则引擎(TD-10 自研数据驱动决策表)",
|
||||
"368": "`packages/tryon/` — 智能试穿与发型(占位)",
|
||||
"369": "`scripts/`",
|
||||
"370": "`services/matting/` — 抠图批处理(占位)",
|
||||
"371": "ClothesPool.vue",
|
||||
"372": "i18n/index.ts",
|
||||
"373": "getHistory",
|
||||
"374": "graphify reference: GitHub clone and cross-repo merge",
|
||||
"375": "graphify reference: transcribe video and audio",
|
||||
"376": "9. 数据流说明",
|
||||
"377": "SubtitleOverlay.test.tsx",
|
||||
"378": "0001_initial_foundation.py",
|
||||
"379": "0002_ai_call_reasoning_and_rate_tier.py",
|
||||
"380": "0004_sessions_and_events.py",
|
||||
"381": "0005_kb_tables.py",
|
||||
"382": "0006_report_tables.py",
|
||||
"383": "puton-master/env.d.ts",
|
||||
"384": "puton-master/src/main.ts",
|
||||
"385": "getModels",
|
||||
"386": "puton-master/tsconfig.json",
|
||||
"387": "manifest.json",
|
||||
"388": "AutoVideo/AGENTS.md",
|
||||
"389": "extraction-spec.md",
|
||||
"390": "@babel/parser",
|
||||
"391": "ms",
|
||||
"392": "pinyin-pro",
|
||||
"393": "puppeteer-core",
|
||||
"394": "B01/Component.tsx",
|
||||
"395": "B02/Component.tsx",
|
||||
"396": "cosyvoice-tts/install.sh",
|
||||
"397": "cosyvoice-tts/start.sh",
|
||||
"398": "musetalk-lipsync/start.sh",
|
||||
"399": "voxcpm-tts/start.sh",
|
||||
"400": "web/env.d.ts",
|
||||
"401": "@element-plus/icons-vue",
|
||||
"402": "eslint-plugin-import",
|
||||
"403": "admin/__init__.py",
|
||||
"404": "api/__init__.py",
|
||||
"405": "apps/__init__.py",
|
||||
"406": "agent/__init__.py",
|
||||
"407": "analytics/__init__.py",
|
||||
"408": "compliance/__init__.py",
|
||||
"409": "domain/__init__.py",
|
||||
"410": "domain/task/__init__.py",
|
||||
"411": "packages/__init__.py",
|
||||
"412": "providers/__init__.py",
|
||||
"413": "memory/__init__.py",
|
||||
"414": "notify/__init__.py",
|
||||
"415": "profile/__init__.py",
|
||||
"416": "shared/__init__.py",
|
||||
"417": "storage/__init__.py",
|
||||
"418": "packages/tryon/__init__.py",
|
||||
"419": "wardrobe/__init__.py",
|
||||
"420": "wardrobe/README.md",
|
||||
"421": "services/__init__.py",
|
||||
"422": "matting/__init__.py",
|
||||
"423": "services/tryon/__init__.py",
|
||||
"424": "services/tryon/README.md",
|
||||
"425": "vision/__init__.py",
|
||||
"426": "swiper",
|
||||
"427": "@types/axios",
|
||||
"428": "vue-router",
|
||||
"429": "@vueuse/integrations",
|
||||
"430": "@rushstack/eslint-patch",
|
||||
"431": "@tsconfig/node20",
|
||||
"432": "typescript",
|
||||
"433": "unplugin-vue-components",
|
||||
"434": "@vitejs/plugin-vue",
|
||||
"435": "vue-tsc",
|
||||
"436": "ScrollWithBar.vue",
|
||||
"437": "constants/index.ts",
|
||||
"438": "downloadFile",
|
||||
"439": "puton-master/src/router/index.ts",
|
||||
"440": "counter.ts",
|
||||
"441": "globalStore.ts",
|
||||
"442": "utils/index.ts",
|
||||
"443": "puton-master/vite.config.ts",
|
||||
"444": "index.d.ts",
|
||||
"445": "remotion.config.ts",
|
||||
"446": "starter/meta.md",
|
||||
"447": "narration.md",
|
||||
"448": "visuals.md",
|
||||
"449": "block1.md",
|
||||
"450": "block2.md",
|
||||
"451": "compile-test/meta.md",
|
||||
"452": "web/vite.config.ts",
|
||||
"453": "analytics/README.md",
|
||||
"454": "storage/README.md",
|
||||
"455": "tests/__init__.py",
|
||||
"456": "integration/__init__.py",
|
||||
"457": "unit/__init__.py",
|
||||
"458": "meida-backend",
|
||||
"459": ".eslintrc.cjs",
|
||||
"460": "IconAdd.vue",
|
||||
"461": "IconDelete.vue",
|
||||
"462": "IconDrag.vue",
|
||||
"463": "IconEdit.vue",
|
||||
"464": "IconLink.vue",
|
||||
"465": "IconSearch.vue",
|
||||
"466": "IconUpload.vue",
|
||||
"467": "MyCard.vue",
|
||||
"468": "SearchInput.vue",
|
||||
"469": "service/api.ts",
|
||||
"470": "vue-router",
|
||||
"471": "vue"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"runs": [
|
||||
{
|
||||
"date": "2026-08-15T09:36:05.184950+00:00",
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"files": 82
|
||||
}
|
||||
],
|
||||
"total_input_tokens": 0,
|
||||
"total_output_tokens": 0
|
||||
}
|
||||