Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5b167e4b6 | ||
|
|
312ef2b716 | ||
|
|
83fd48b910 | ||
|
|
db9c4da0e3 |
@@ -19,3 +19,6 @@ graphify-out/.graphify_python
|
||||
graphify-out/.graphify_root
|
||||
graphify-out/.vocab.txt
|
||||
graphify-out/cache/
|
||||
|
||||
# hvigor 构建缓存
|
||||
.hvigor/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
@echo off
|
||||
set "PATH=C:\Program Files\Huawei\DevEco Studio\jbr\bin;%PATH%"
|
||||
set "DEVECO_SDK_HOME=C:\Program Files\Huawei\DevEco Studio\sdk"
|
||||
echo [preflight] where java:
|
||||
where java
|
||||
echo [preflight] node spawn test:
|
||||
"C:\Program Files\Huawei\DevEco Studio\tools\node\node.exe" D:\meida\app\.spawn-test.js
|
||||
cd /d D:\meida\app
|
||||
echo [build] start
|
||||
"C:\Program Files\Huawei\DevEco Studio\tools\node\node.exe" "C:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.js" --mode module -p module=entry@default assembleHap --analyze=false --daemon=false
|
||||
@@ -2,7 +2,7 @@
|
||||
"string": [
|
||||
{
|
||||
"name": "app_name",
|
||||
"value": "app"
|
||||
"value": "美搭"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 700 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 12 KiB |
@@ -6,15 +6,23 @@
|
||||
|
||||
## 命令行构建(Agent/CI 用的门禁)
|
||||
|
||||
```bash
|
||||
cd G:/meida/app
|
||||
export DEVECO_SDK_HOME="C:/Program Files/Huawei/DevEco Studio/sdk"
|
||||
"C:/Program Files/Huawei/DevEco Studio/tools/node/node.exe" \
|
||||
"C:/Program Files/Huawei/DevEco Studio/tools/hvigor/bin/hvigorw.js" \
|
||||
> 工程配了真机调试签名(`.ohos/config` 下证书)后,PackageHap/SignHap 需要 java:
|
||||
> PATH 里须有 DevEco 自带 JBR(`jbr/bin`),且**不能有带旧环境的 hvigor 守护进程残留**
|
||||
> (守护进程会缓存启动时的 PATH,报 `spawn java ENOENT` 时先
|
||||
> `taskkill /F /PID <hvigor daemon的node进程>` 再重试)。
|
||||
|
||||
```bat
|
||||
(等价于仓库根的 app/.build-gate.bat)
|
||||
set "PATH=C:\Program Files\Huawei\DevEco Studio\jbr\bin;%PATH%"
|
||||
set "DEVECO_SDK_HOME=C:\Program Files\Huawei\DevEco Studio\sdk"
|
||||
cd /d <repo>\app
|
||||
"C:\Program Files\Huawei\DevEco Studio\tools\node\node.exe" ^
|
||||
"C:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.js" ^
|
||||
--mode module -p module=entry@default assembleHap --analyze=false --daemon=false
|
||||
```
|
||||
|
||||
`BUILD SUCCESSFUL` 即门禁通过(未签名 HAP 正常——签名等 AGC 证书)。运行/调试用 DevEco Studio 打开本工程点 Run(模拟器或真机,真机需先配签名)。
|
||||
`BUILD SUCCESSFUL` 即门禁通过;产物 `entry/build/default/outputs/default/entry-default-signed.hap`
|
||||
(已用调试证书签名,可直接 `hdc install` 到真机)。
|
||||
|
||||
## 代码约定
|
||||
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
{
|
||||
"app": {
|
||||
"signingConfigs": [],
|
||||
"signingConfigs": [
|
||||
{
|
||||
"name": "default",
|
||||
"type": "HarmonyOS",
|
||||
"material": {
|
||||
"certpath": "C:\\Users\\youmi\\.ohos\\config\\default_app_7oyN4SmerXoqWpORnThdj7HJQc-Uvz9AusJXlFjQgt4=.cer",
|
||||
"keyAlias": "debugKey",
|
||||
"keyPassword": "0000001BE20911DD8845DD093D7A01CBE5481C81764769809C9BF80D93020A1FDCEB9432FB5C08DDEF808B",
|
||||
"profile": "C:\\Users\\youmi\\.ohos\\config\\default_app_7oyN4SmerXoqWpORnThdj7HJQc-Uvz9AusJXlFjQgt4=.p7b",
|
||||
"signAlg": "SHA256withECDSA",
|
||||
"storeFile": "C:\\Users\\youmi\\.ohos\\config\\default_app_7oyN4SmerXoqWpORnThdj7HJQc-Uvz9AusJXlFjQgt4=.p12",
|
||||
"storePassword": "0000001BDD82AF3B7F4DC062470C6F6831D38E94E5BC584F4E4439362CDE8E950D873B51744CA0614B4D94"
|
||||
}
|
||||
}
|
||||
],
|
||||
"products": [
|
||||
{
|
||||
"name": "default",
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* ProfileContent —— 个人中心内容组件(可内嵌 Tab,也可被 ProfilePage 路由壳复用)。
|
||||
*
|
||||
* 与 ProfilePage 的分工:本组件承载全部 UI 与数据逻辑;路由壳只负责 @Entry。
|
||||
* 未登录时不再强制 router.replaceUrl(内嵌 Tab 时会劫持整个应用壳),
|
||||
* 改为页内提示 + 「去登录」按钮。
|
||||
*
|
||||
* 数据契约(全部走 ApiClient/AuthStore,不直接碰 http):
|
||||
* - 配额:GET /api/v1/quota?quota_key=agent_chat|tryon|report_basic(并行拉取)
|
||||
* - 流水:GET /api/v1/quota/ledger?limit=10
|
||||
* 周期文案按架构文档 §7.4 配额映射:agent_chat/tryon=日、report_basic=终身一次。
|
||||
*/
|
||||
|
||||
import router from '@ohos.router'
|
||||
import { ApiClient } from '../../core/ApiClient'
|
||||
import { ApiError } from '../../core/ApiTypes'
|
||||
import { AuthStore } from '../../core/AuthStore'
|
||||
import { ErrorBanner } from './ErrorBanner'
|
||||
import { QuotaCardView } from './QuotaCard'
|
||||
import { LedgerItemView } from './LedgerItem'
|
||||
|
||||
/** GET /api/v1/quota 响应(字段名对齐后端 JSON)。 */
|
||||
interface QuotaWire {
|
||||
quota_key: string
|
||||
used: number
|
||||
limit_value: number
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
reset_at: string | null
|
||||
}
|
||||
|
||||
/** GET /api/v1/quota/ledger 数组元素。 */
|
||||
interface LedgerWire {
|
||||
tx_id: string
|
||||
delta: number
|
||||
tx_type: string
|
||||
status: string
|
||||
quota_key: string
|
||||
balance_after: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 配额卡片的 UI 状态模型。 */
|
||||
class QuotaCardData {
|
||||
title: string
|
||||
used: number = 0
|
||||
total: number = 0
|
||||
remaining: number = 0
|
||||
resetText: string = ''
|
||||
loaded: boolean = false
|
||||
errorText: string = ''
|
||||
|
||||
constructor(title: string) {
|
||||
this.title = title
|
||||
}
|
||||
}
|
||||
|
||||
/** 流水行的 UI 状态模型(映射/配色在构造期完成)。 */
|
||||
class LedgerRowData {
|
||||
typeText: string = ''
|
||||
statusText: string = ''
|
||||
statusColor: string = '#999999'
|
||||
statusBg: string = '#F4F4F4'
|
||||
quotaText: string = ''
|
||||
timeText: string = ''
|
||||
deltaText: string = ''
|
||||
deltaColor: string = '#666666'
|
||||
balanceText: string = ''
|
||||
}
|
||||
|
||||
const COLOR_POSITIVE = '#2E9E5B'
|
||||
const COLOR_NEGATIVE = '#C0392B'
|
||||
const COLOR_MUTED = '#8A8A8A'
|
||||
|
||||
/** quota_key → 中文名(对齐产品方案 §3.2 / 架构文档 §7.4)。 */
|
||||
function quotaName(key: string): string {
|
||||
if (key === 'agent_chat') {
|
||||
return 'AI 对话'
|
||||
}
|
||||
if (key === 'tryon') {
|
||||
return '换装试穿'
|
||||
}
|
||||
if (key === 'hairstyle') {
|
||||
return '发型试戴'
|
||||
}
|
||||
if (key === 'hd_export') {
|
||||
return '高清导出'
|
||||
}
|
||||
if (key === 'report_basic') {
|
||||
return '美学报告'
|
||||
}
|
||||
if (key === 'report_regen') {
|
||||
return '报告重新生成'
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
/** 是否日配额(决定「明日恢复」文案)。 */
|
||||
function isDailyQuota(key: string): boolean {
|
||||
return key === 'agent_chat' || key === 'tryon' || key === 'hairstyle' || key === 'hd_export'
|
||||
}
|
||||
|
||||
/** 周期文案:日配额=明日恢复;月/终身有 reset_at 显示日期;null 按周期区分。 */
|
||||
function resetTextFor(key: string, resetAt: string | null): string {
|
||||
if (resetAt === null || resetAt === '') {
|
||||
if (key === 'report_basic') {
|
||||
return '一次性额度'
|
||||
}
|
||||
if (key === 'report_regen') {
|
||||
return '每月重置'
|
||||
}
|
||||
return '每日重置'
|
||||
}
|
||||
if (isDailyQuota(key)) {
|
||||
return '明日恢复'
|
||||
}
|
||||
const short = resetAt.length >= 10 ? resetAt.substring(5, 10) : resetAt
|
||||
return `${short} 重置`
|
||||
}
|
||||
|
||||
/** tx_type → 中文(V1 三种,V2 预留 purchase/gift)。 */
|
||||
function txTypeText(t: string): string {
|
||||
if (t === 'consume') {
|
||||
return '消耗'
|
||||
}
|
||||
if (t === 'refund') {
|
||||
return '退还'
|
||||
}
|
||||
if (t === 'system_grant') {
|
||||
return '奖励'
|
||||
}
|
||||
if (t === 'purchase') {
|
||||
return '购买'
|
||||
}
|
||||
if (t === 'gift') {
|
||||
return '赠送'
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/** status → 中文(预扣三段式:reserved → committed;退还写反向流水)。 */
|
||||
function statusTextOf(s: string): string {
|
||||
if (s === 'reserved') {
|
||||
return '进行中'
|
||||
}
|
||||
if (s === 'committed') {
|
||||
return '已入账'
|
||||
}
|
||||
if (s === 'refunded') {
|
||||
return '已退还'
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function statusColorOf(s: string): string {
|
||||
if (s === 'reserved') {
|
||||
return '#B9770E'
|
||||
}
|
||||
if (s === 'committed') {
|
||||
return COLOR_POSITIVE
|
||||
}
|
||||
if (s === 'refunded') {
|
||||
return COLOR_MUTED
|
||||
}
|
||||
return '#999999'
|
||||
}
|
||||
|
||||
function statusBgOf(s: string): string {
|
||||
if (s === 'reserved') {
|
||||
return '#FBF0DC'
|
||||
}
|
||||
if (s === 'committed') {
|
||||
return '#E4F3EA'
|
||||
}
|
||||
return '#F4F4F4'
|
||||
}
|
||||
|
||||
/** ISO 时间 → 'MM-DD HH:mm'。 */
|
||||
function timeText(iso: string): string {
|
||||
if (iso === '') {
|
||||
return '—'
|
||||
}
|
||||
const t = iso.replace('T', ' ')
|
||||
return t.length >= 16 ? t.substring(5, 16) : t
|
||||
}
|
||||
|
||||
/** 用户 ID 脱敏:前 6 后 4。 */
|
||||
function maskUserId(id: string): string {
|
||||
if (id === '') {
|
||||
return '未登录'
|
||||
}
|
||||
if (id.length <= 10) {
|
||||
return id
|
||||
}
|
||||
return `${id.substring(0, 6)}…${id.substring(id.length - 4)}`
|
||||
}
|
||||
|
||||
/** wire → 行视图模型(对象字面量禁裸用,故用 class + 字段赋值)。 */
|
||||
function toLedgerRow(w: LedgerWire): LedgerRowData {
|
||||
const row = new LedgerRowData()
|
||||
row.typeText = txTypeText(w.tx_type)
|
||||
row.statusText = statusTextOf(w.status)
|
||||
row.statusColor = statusColorOf(w.status)
|
||||
row.statusBg = statusBgOf(w.status)
|
||||
row.quotaText = quotaName(w.quota_key)
|
||||
row.timeText = timeText(w.created_at)
|
||||
row.deltaText = w.delta > 0 ? `+${w.delta}` : `${w.delta}`
|
||||
row.deltaColor = w.delta > 0 ? COLOR_POSITIVE : (w.delta < 0 ? COLOR_NEGATIVE : COLOR_MUTED)
|
||||
row.balanceText = `余额 ${w.balance_after}`
|
||||
return row
|
||||
}
|
||||
|
||||
function toApiError(e: Object): ApiError {
|
||||
if (e instanceof ApiError) {
|
||||
return e
|
||||
}
|
||||
return new ApiError(50000, '请求失败,请检查网络', '请稍后重试')
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct ProfileContent {
|
||||
@State needLogin: boolean = false
|
||||
@State userIdMasked: string = ''
|
||||
@State cardChat: QuotaCardData = new QuotaCardData('AI 对话')
|
||||
@State cardTryon: QuotaCardData = new QuotaCardData('换装试穿')
|
||||
@State cardReport: QuotaCardData = new QuotaCardData('美学报告')
|
||||
@State ledgerRows: LedgerRowData[] = []
|
||||
@State ledgerLoaded: boolean = false
|
||||
@State ledgerError: string = ''
|
||||
@State bannerMessage: string = ''
|
||||
@State bannerHint: string = ''
|
||||
@State refreshing: boolean = false
|
||||
@State loggingOut: boolean = false
|
||||
private pendingLoads: number = 0
|
||||
|
||||
aboutToAppear(): void {
|
||||
// 内嵌 Tab 场景下不能强制跳转登录页(会劫持整个应用壳),
|
||||
// 未登录展示页内提示;ProfilePage 路由壳另有拦截
|
||||
if (!AuthStore.isLoggedIn()) {
|
||||
this.needLogin = true
|
||||
return
|
||||
}
|
||||
const uid = AuthStore.getUserId()
|
||||
this.userIdMasked = maskUserId(uid !== undefined ? uid : '')
|
||||
this.startLoadAll()
|
||||
}
|
||||
|
||||
/** 并行拉三张配额卡 + 流水;计数归零时收起下拉刷新。 */
|
||||
private startLoadAll(): void {
|
||||
this.bannerMessage = ''
|
||||
this.bannerHint = ''
|
||||
this.ledgerError = ''
|
||||
this.cardChat = new QuotaCardData('AI 对话')
|
||||
this.cardTryon = new QuotaCardData('换装试穿')
|
||||
this.cardReport = new QuotaCardData('美学报告')
|
||||
this.pendingLoads = 4
|
||||
this.loadQuota('agent_chat')
|
||||
this.loadQuota('tryon')
|
||||
this.loadQuota('report_basic')
|
||||
this.loadLedger()
|
||||
}
|
||||
|
||||
private finishOne(): void {
|
||||
if (this.pendingLoads > 0) {
|
||||
this.pendingLoads -= 1
|
||||
}
|
||||
if (this.pendingLoads === 0) {
|
||||
this.refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
private async loadQuota(key: string): Promise<void> {
|
||||
try {
|
||||
const q = await ApiClient.get<QuotaWire>(`/api/v1/quota?quota_key=${key}`)
|
||||
const data = new QuotaCardData(quotaName(key))
|
||||
data.used = q.used
|
||||
data.total = q.limit_value + q.bonus_granted
|
||||
data.remaining = q.remaining
|
||||
data.resetText = resetTextFor(key, q.reset_at)
|
||||
data.loaded = true
|
||||
this.applyQuota(key, data)
|
||||
} catch (e) {
|
||||
// 单卡失败不拖垮整页:错误落在卡片内
|
||||
const err = toApiError(e)
|
||||
const data = new QuotaCardData(quotaName(key))
|
||||
data.loaded = true
|
||||
data.errorText = err.message
|
||||
this.applyQuota(key, data)
|
||||
} finally {
|
||||
this.finishOne()
|
||||
}
|
||||
}
|
||||
|
||||
private applyQuota(key: string, data: QuotaCardData): void {
|
||||
if (key === 'agent_chat') {
|
||||
this.cardChat = data
|
||||
} else if (key === 'tryon') {
|
||||
this.cardTryon = data
|
||||
} else {
|
||||
this.cardReport = data
|
||||
}
|
||||
}
|
||||
|
||||
private async loadLedger(): Promise<void> {
|
||||
try {
|
||||
const list = await ApiClient.get<LedgerWire[]>('/api/v1/quota/ledger?limit=10')
|
||||
const rows: LedgerRowData[] = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
rows.push(toLedgerRow(list[i]))
|
||||
}
|
||||
this.ledgerRows = rows
|
||||
this.ledgerLoaded = true
|
||||
this.ledgerError = ''
|
||||
} catch (e) {
|
||||
const err = toApiError(e)
|
||||
this.ledgerLoaded = true
|
||||
this.ledgerError = err.hint !== undefined && err.hint !== '' ? `${err.message}(${err.hint})` : err.message
|
||||
} finally {
|
||||
this.finishOne()
|
||||
}
|
||||
}
|
||||
|
||||
private confirmLogout(): void {
|
||||
AlertDialog.show({
|
||||
title: '确认登出',
|
||||
message: '登出后本地会话将清除,配额与报告数据仍保留在账号下。',
|
||||
primaryButton: {
|
||||
value: '取消',
|
||||
action: () => {
|
||||
// 仅关闭弹窗
|
||||
}
|
||||
},
|
||||
secondaryButton: {
|
||||
value: '登出',
|
||||
fontColor: '#C0392B',
|
||||
action: () => this.performLogout()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async performLogout(): Promise<void> {
|
||||
if (this.loggingOut) {
|
||||
return
|
||||
}
|
||||
this.loggingOut = true
|
||||
try {
|
||||
await AuthStore.logout()
|
||||
} finally {
|
||||
this.loggingOut = false
|
||||
// 内嵌 Tab 时 replaceUrl 替换掉的是整个 HomePage 应用壳,行为正确
|
||||
router.replaceUrl({ url: 'pages/LoginPage' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 未登录占位:页内提示 + 去登录(不强制跳转)。 */
|
||||
@Builder
|
||||
LoginPrompt() {
|
||||
Column({ space: 12 }) {
|
||||
Text('☺')
|
||||
.fontSize(44)
|
||||
.fontColor('#C2C7CE')
|
||||
Text('请先登录')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#2B2B2B')
|
||||
Text('登录后可查看配额与流水')
|
||||
.fontSize(13)
|
||||
.fontColor('#9A9A9A')
|
||||
Button('去登录')
|
||||
.fontSize(15)
|
||||
.fontColor(Color.White)
|
||||
.backgroundColor('#C2185B')
|
||||
.borderRadius(22)
|
||||
.height(44)
|
||||
.padding({ left: 28, right: 28 })
|
||||
.margin({ top: 8 })
|
||||
.onClick(() => {
|
||||
router.pushUrl({ url: 'pages/LoginPage' })
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
|
||||
build() {
|
||||
if (this.needLogin) {
|
||||
this.LoginPrompt()
|
||||
} else {
|
||||
Refresh({ refreshing: $$this.refreshing }) {
|
||||
Scroll() {
|
||||
Column({ space: 14 }) {
|
||||
// 顶部:用户信息 + 登出
|
||||
Row({ space: 12 }) {
|
||||
Column() {
|
||||
Text('美')
|
||||
.fontSize(24)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(56)
|
||||
.height(56)
|
||||
.borderRadius(28)
|
||||
.backgroundColor('#C2185B')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text('美搭用户')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#2B2B2B')
|
||||
Text(`ID ${this.userIdMasked}`)
|
||||
.fontSize(12)
|
||||
.fontColor('#9A9A9A')
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
|
||||
Button(this.loggingOut ? '登出中…' : '登出')
|
||||
.fontSize(13)
|
||||
.height(32)
|
||||
.borderRadius(16)
|
||||
.padding({ left: 14, right: 14 })
|
||||
.backgroundColor('#F7E3EA')
|
||||
.fontColor('#C2185B')
|
||||
.enabled(!this.loggingOut)
|
||||
.onClick(() => this.confirmLogout())
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(16)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
ErrorBanner({
|
||||
message: this.bannerMessage,
|
||||
hint: this.bannerHint,
|
||||
onClose: () => {
|
||||
this.bannerMessage = ''
|
||||
this.bannerHint = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 配额三卡
|
||||
Row({ space: 10 }) {
|
||||
QuotaCardView({
|
||||
title: this.cardChat.title,
|
||||
remaining: this.cardChat.remaining,
|
||||
used: this.cardChat.used,
|
||||
total: this.cardChat.total,
|
||||
resetText: this.cardChat.resetText,
|
||||
loaded: this.cardChat.loaded,
|
||||
errorText: this.cardChat.errorText,
|
||||
accent: '#C2185B'
|
||||
})
|
||||
.layoutWeight(1)
|
||||
|
||||
QuotaCardView({
|
||||
title: this.cardTryon.title,
|
||||
remaining: this.cardTryon.remaining,
|
||||
used: this.cardTryon.used,
|
||||
total: this.cardTryon.total,
|
||||
resetText: this.cardTryon.resetText,
|
||||
loaded: this.cardTryon.loaded,
|
||||
errorText: this.cardTryon.errorText,
|
||||
accent: '#7A5AF8'
|
||||
})
|
||||
.layoutWeight(1)
|
||||
|
||||
QuotaCardView({
|
||||
title: this.cardReport.title,
|
||||
remaining: this.cardReport.remaining,
|
||||
used: this.cardReport.used,
|
||||
total: this.cardReport.total,
|
||||
resetText: this.cardReport.resetText,
|
||||
loaded: this.cardReport.loaded,
|
||||
errorText: this.cardReport.errorText,
|
||||
accent: '#2E9E5B'
|
||||
})
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
|
||||
// 最近流水
|
||||
Column({ space: 4 }) {
|
||||
Text('最近流水')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#2B2B2B')
|
||||
.width('100%')
|
||||
|
||||
if (!this.ledgerLoaded) {
|
||||
Text('加载中…')
|
||||
.fontSize(13)
|
||||
.fontColor('#BBBBBB')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 24, bottom: 24 })
|
||||
} else if (this.ledgerError !== '') {
|
||||
Text(this.ledgerError)
|
||||
.fontSize(13)
|
||||
.fontColor('#C0392B')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 24, bottom: 24 })
|
||||
} else if (this.ledgerRows.length === 0) {
|
||||
Text('暂无流水记录')
|
||||
.fontSize(13)
|
||||
.fontColor('#BBBBBB')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 24, bottom: 24 })
|
||||
} else {
|
||||
ForEach(this.ledgerRows, (row: LedgerRowData) => {
|
||||
LedgerItemView({
|
||||
typeText: row.typeText,
|
||||
statusText: row.statusText,
|
||||
statusColor: row.statusColor,
|
||||
statusBg: row.statusBg,
|
||||
quotaText: row.quotaText,
|
||||
timeText: row.timeText,
|
||||
deltaText: row.deltaText,
|
||||
deltaColor: row.deltaColor,
|
||||
balanceText: row.balanceText
|
||||
})
|
||||
}, (row: LedgerRowData) => `${row.typeText}-${row.quotaText}-${row.timeText}`)
|
||||
}
|
||||
|
||||
if (this.ledgerLoaded && this.ledgerRows.length > 0) {
|
||||
Text('仅展示最近 10 条,下拉可刷新')
|
||||
.fontSize(11)
|
||||
.fontColor('#B3B3B3')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(16)
|
||||
.backgroundColor('#FFFFFF')
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 14, bottom: 32 })
|
||||
}
|
||||
.scrollBar(BarState.Off)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
.width('100%')
|
||||
}
|
||||
.onRefreshing(() => {
|
||||
this.startLoadAll()
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* TtsEngine —— AI 回复语音朗读(CoreSpeechKit textToSpeech)。
|
||||
*
|
||||
* V2(真机修复版):
|
||||
* - 长文本分段:单次 speak 文本上限 500 字符(1002300001),超长直接失败。
|
||||
* 按句子边界切 ≤400 字符/段,在 onComplete(type=1 播报完成) 回调里串行
|
||||
* 播下一段,150 秒兜底定时器防回调丢失卡死链条
|
||||
* - 不依赖 queueMode:官方 API 文档(0=flush)与 Codelabs(0=排队)说法
|
||||
* 互相矛盾,新朗读前显式 engine.stop() 打断,语义自持
|
||||
* - 播报状态回调 onStateChanged:页面据此复位 🔊 图标
|
||||
*
|
||||
* 模型:offline 中文引擎(online: 1,系统内置);模拟器通常无此服务,须真机。
|
||||
* 日志 tag MEIDA_TTS:hdc shell hilog | grep MEIDA_TTS
|
||||
*/
|
||||
|
||||
import { textToSpeech } from '@kit.CoreSpeechKit'
|
||||
|
||||
const SEGMENT_MAX_CHARS: number = 400
|
||||
const SEGMENT_FALLBACK_MS: number = 150000
|
||||
|
||||
/** 播报状态变化回调(speaking=false 表示本轮朗读完全结束或被停止)。 */
|
||||
export type TtsStateChanged = (speaking: boolean) => void
|
||||
|
||||
function splitSegments(text: string, maxChars: number): string[] {
|
||||
const segments: string[] = []
|
||||
let current: string = ''
|
||||
const sentenceEnds: string[] = ['。', '!', '?', ';', '\n']
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
current += text[i]
|
||||
const atSentenceEnd: boolean = sentenceEnds.indexOf(text[i]) >= 0
|
||||
if ((atSentenceEnd && current.length >= 16) || current.length >= maxChars) {
|
||||
segments.push(current)
|
||||
current = ''
|
||||
}
|
||||
}
|
||||
if (current !== '') {
|
||||
segments.push(current)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
export class TtsEngine {
|
||||
private engine: textToSpeech.TextToSpeechEngine | null = null
|
||||
private queue: string[] = []
|
||||
private currentRequestId: string = ''
|
||||
private requestSeq: number = 0
|
||||
private speaking: boolean = false
|
||||
private fallbackTimer: number = -1
|
||||
private stateChanged: TtsStateChanged | null = null
|
||||
|
||||
/** 页面注入状态回调(如复位朗读高亮图标)。 */
|
||||
setOnStateChanged(cb: TtsStateChanged): void {
|
||||
this.stateChanged = cb
|
||||
}
|
||||
|
||||
private async ensureEngine(): Promise<void> {
|
||||
if (this.engine !== null) {
|
||||
return
|
||||
}
|
||||
const extraParam: Record<string, Object> = {
|
||||
'style': 'interaction-broadcast',
|
||||
'locate': 'CN',
|
||||
'name': 'MeiDaTTS'
|
||||
}
|
||||
const createParams: textToSpeech.CreateEngineParams = {
|
||||
language: 'zh-CN',
|
||||
person: 0,
|
||||
online: 1,
|
||||
extraParams: extraParam
|
||||
}
|
||||
this.engine = await textToSpeech.createEngine(createParams)
|
||||
console.info('MEIDA_TTS engine created')
|
||||
this.attachListener()
|
||||
}
|
||||
|
||||
/**
|
||||
* 朗读入口:切段入队并立即播首段。引擎创建失败会抛 BusinessError,
|
||||
* 由调用方决定是否提示(自动朗读静默、手动点击提示)。
|
||||
*/
|
||||
async speak(text: string): Promise<void> {
|
||||
const trimmed: string = text.trim()
|
||||
if (trimmed.length === 0) {
|
||||
return
|
||||
}
|
||||
await this.ensureEngine()
|
||||
this.stop()
|
||||
this.queue = splitSegments(trimmed, SEGMENT_MAX_CHARS)
|
||||
console.info(`MEIDA_TTS speak start: ${trimmed.length} chars, ${this.queue.length} segment(s)`)
|
||||
this.speakNext()
|
||||
}
|
||||
|
||||
/** 串行驱动:每段播完(onComplete type=1)再播下一段。 */
|
||||
private speakNext(): void {
|
||||
if (this.queue.length === 0) {
|
||||
this.finishSpeaking()
|
||||
return
|
||||
}
|
||||
if (this.engine === null) {
|
||||
this.finishSpeaking()
|
||||
return
|
||||
}
|
||||
const segment: string = this.queue.shift() ?? ''
|
||||
if (segment === '') {
|
||||
this.finishSpeaking()
|
||||
return
|
||||
}
|
||||
this.requestSeq += 1
|
||||
this.currentRequestId = `meida-tts-${this.requestSeq}-${Date.now()}`
|
||||
this.speaking = true
|
||||
if (this.stateChanged !== null) {
|
||||
this.stateChanged(true)
|
||||
}
|
||||
const speakExtra: Record<string, Object> = {
|
||||
'queueMode': 0,
|
||||
'speed': 1,
|
||||
'volume': 2,
|
||||
'pitch': 1,
|
||||
'languageContext': 'zh-CN',
|
||||
'audioType': 'pcm',
|
||||
'soundChannel': 3,
|
||||
'playType': 1
|
||||
}
|
||||
const speakParams: textToSpeech.SpeakParams = {
|
||||
requestId: this.currentRequestId,
|
||||
extraParams: speakExtra
|
||||
}
|
||||
this.armFallback()
|
||||
try {
|
||||
this.engine.speak(segment, speakParams)
|
||||
} catch (e) {
|
||||
console.error(`MEIDA_TTS segment speak throw: ${String(e)}`)
|
||||
this.clearFallback()
|
||||
this.finishSpeaking()
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止朗读并清空待播分段。幂等:未在播/引擎为空也安全。 */
|
||||
stop(): void {
|
||||
this.queue = []
|
||||
this.clearFallback()
|
||||
if (this.engine !== null) {
|
||||
try {
|
||||
this.engine.stop()
|
||||
} catch (_e) {
|
||||
// 空闲态调用 stop 可能抛错:忽略
|
||||
}
|
||||
}
|
||||
if (this.speaking) {
|
||||
this.speaking = false
|
||||
if (this.stateChanged !== null) {
|
||||
this.stateChanged(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isSpeaking(): boolean {
|
||||
return this.speaking
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.stop()
|
||||
const engine = this.engine
|
||||
this.engine = null
|
||||
if (engine !== null) {
|
||||
try {
|
||||
engine.shutdown()
|
||||
} catch (_e) {
|
||||
// 页面销毁路径,失败即随对象丢弃
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private finishSpeaking(): void {
|
||||
this.queue = []
|
||||
this.clearFallback()
|
||||
if (this.speaking) {
|
||||
this.speaking = false
|
||||
console.info('MEIDA_TTS all segments done')
|
||||
if (this.stateChanged !== null) {
|
||||
this.stateChanged(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 某段回调丢失时强制推进,避免长文朗读链条卡死。 */
|
||||
private armFallback(): void {
|
||||
this.clearFallback()
|
||||
this.fallbackTimer = setTimeout(() => {
|
||||
console.warn(`MEIDA_TTS segment fallback fire, ${this.queue.length} segment(s) left`)
|
||||
this.speakNext()
|
||||
}, SEGMENT_FALLBACK_MS)
|
||||
}
|
||||
|
||||
private clearFallback(): void {
|
||||
if (this.fallbackTimer >= 0) {
|
||||
clearTimeout(this.fallbackTimer)
|
||||
this.fallbackTimer = -1
|
||||
}
|
||||
}
|
||||
|
||||
private attachListener(): void {
|
||||
const engine = this.engine
|
||||
if (engine === null) {
|
||||
return
|
||||
}
|
||||
engine.setListener({
|
||||
onStart: (requestId: string, response: textToSpeech.StartResponse) => {
|
||||
console.info(`MEIDA_TTS onStart rid=${requestId}`)
|
||||
},
|
||||
onComplete: (requestId: string, response: textToSpeech.CompleteResponse) => {
|
||||
if (requestId !== this.currentRequestId) {
|
||||
return
|
||||
}
|
||||
// type=0 合成完成、type=1 播报完成:只等播报完成才推进下一段
|
||||
if (response.type === 1) {
|
||||
console.info(`MEIDA_TTS segment done, ${this.queue.length} segment(s) left`)
|
||||
this.clearFallback()
|
||||
this.speakNext()
|
||||
}
|
||||
},
|
||||
onStop: (requestId: string, response: textToSpeech.StopResponse) => {
|
||||
console.info(`MEIDA_TTS onStop rid=${requestId}`)
|
||||
if (requestId !== this.currentRequestId) {
|
||||
return
|
||||
}
|
||||
this.finishSpeaking()
|
||||
},
|
||||
onData: (requestId: string, audio: ArrayBuffer, response: textToSpeech.SynthesisResponse) => {
|
||||
},
|
||||
onError: (requestId: string, errorCode: number, errorMessage: string) => {
|
||||
console.error(`MEIDA_TTS onError rid=${requestId} code=${errorCode} msg=${errorMessage}`)
|
||||
if (requestId !== this.currentRequestId) {
|
||||
return
|
||||
}
|
||||
this.finishSpeaking()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* VoiceInputEngine —— 语音输入封装(CoreSpeechKit speechRecognizer)。
|
||||
*
|
||||
* V2(真机修复版,V1/中间版按住即报 1002200006 busy):
|
||||
* - 根因:recognitionMode 0 是「引擎自采麦克风」模式,之前却自建 AudioCapturer
|
||||
* 喂 writeAudio(那对应 mode 1),双采集方冲突 → 引擎 busy。
|
||||
* V2 改为纯 mode 0:startListening 后引擎自己录音,本类不再碰 AudioKit,
|
||||
* PCM 分块写入层(writeAudio 仅收 640/1280 字节)随之整体删除
|
||||
* - busy 自愈:start 前 isBusy() 轮询等待;超时判定卡死 → shutdown 重建
|
||||
* (引擎全系统单实例,其他应用占用时也可能 busy)
|
||||
* - finish 后等 onResult/isLast 或 onComplete 交付结果,5 秒兜底防回调丢失;
|
||||
* onError 后复位会话状态,坏状态不跨会话存活
|
||||
*
|
||||
* 权限/模型:ohos.permission.MICROPHONE(user_grant,运行时申请);
|
||||
* online: 1 = 离线中文引擎(系统内置,无需下载;模拟器通常无此服务,须真机)。
|
||||
* 日志 tag MEIDA_ASR,真机排查:hdc shell hilog | grep MEIDA_ASR
|
||||
*/
|
||||
|
||||
import { speechRecognizer } from '@kit.CoreSpeechKit'
|
||||
import { abilityAccessCtrl, Permissions } from '@kit.AbilityKit'
|
||||
import { ContextHolder } from '../../core/ContextHolder'
|
||||
|
||||
const FINISH_FALLBACK_MS: number = 5000
|
||||
const BUSY_WAIT_MAX_MS: number = 1500
|
||||
const BUSY_WAIT_STEP_MS: number = 100
|
||||
const RECREATE_WAIT_MS: number = 500
|
||||
|
||||
/** 引擎回调(由 ChatPage 注入)。 */
|
||||
export interface VoiceInputCallbacks {
|
||||
onPartial: (text: string) => void
|
||||
onFinal: (text: string) => void
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
export class VoiceInputEngine {
|
||||
private engine: speechRecognizer.SpeechRecognitionEngine | null = null
|
||||
private callbacks: VoiceInputCallbacks | null = null
|
||||
private sessionId: string = ''
|
||||
private sessionSeq: number = 0
|
||||
private recording: boolean = false
|
||||
private finalized: boolean = true
|
||||
private lastText: string = ''
|
||||
|
||||
setCallbacks(cb: VoiceInputCallbacks): void {
|
||||
this.callbacks = cb
|
||||
}
|
||||
|
||||
/** 麦克风权限(user_grant):未授权时弹系统授权弹窗,拒绝/失败返回 false。 */
|
||||
static async ensureMicrophonePermission(): Promise<boolean> {
|
||||
try {
|
||||
const atManager = abilityAccessCtrl.createAtManager()
|
||||
const perms: Array<Permissions> = ['ohos.permission.MICROPHONE']
|
||||
const result = await atManager.requestPermissionsFromUser(ContextHolder.get(), perms)
|
||||
return result.authResults.length > 0 && result.authResults[0] === 0
|
||||
} catch (_e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按下开始一次识别。引擎常驻复用(省去每次按下的冷启动延迟);
|
||||
* 失败抛 BusinessError,由调用方 catch 后提示。
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.recording) {
|
||||
return
|
||||
}
|
||||
if (this.engine === null) {
|
||||
this.engine = await VoiceInputEngine.createEngineWithRetry()
|
||||
this.attachListener()
|
||||
}
|
||||
await this.waitEngineIdle()
|
||||
if (this.engine === null) {
|
||||
throw new Error('asr engine lost after idle wait')
|
||||
}
|
||||
this.sessionSeq += 1
|
||||
this.sessionId = `meida-asr-${this.sessionSeq}-${Date.now()}`
|
||||
this.lastText = ''
|
||||
this.finalized = false
|
||||
const audioInfo: speechRecognizer.AudioInfo = {
|
||||
audioType: 'pcm',
|
||||
sampleRate: 16000,
|
||||
soundChannel: 1,
|
||||
sampleBit: 16
|
||||
}
|
||||
const startExtra: Record<string, Object> = {
|
||||
'recognitionMode': 0,
|
||||
'maxAudioDuration': 60000
|
||||
}
|
||||
const startParams: speechRecognizer.StartParams = {
|
||||
sessionId: this.sessionId,
|
||||
audioInfo: audioInfo,
|
||||
extraParams: startExtra
|
||||
}
|
||||
console.info(`MEIDA_ASR startListening sid=${this.sessionId}`)
|
||||
try {
|
||||
this.engine.startListening(startParams)
|
||||
} catch (e) {
|
||||
// 会话未建立:立即复位,坏状态不跨会话存活
|
||||
console.error(`MEIDA_ASR startListening throw: ${String(e)}`)
|
||||
this.finalized = true
|
||||
throw e as Error
|
||||
}
|
||||
this.recording = true
|
||||
}
|
||||
|
||||
/** 松手:结束识别,最终结果经 onFinal 回调(无结果时回调空串)。 */
|
||||
finish(): void {
|
||||
if (!this.recording) {
|
||||
return
|
||||
}
|
||||
this.recording = false
|
||||
const sid: string = this.sessionId
|
||||
// 回调丢失兜底:5 秒后强制交付已有文本(期间已开新一轮会话则作废)
|
||||
setTimeout(() => {
|
||||
if (!this.finalized && this.sessionId === sid) {
|
||||
console.warn(`MEIDA_ASR finish fallback fire sid=${sid}`)
|
||||
this.deliverFinal(this.lastText)
|
||||
}
|
||||
}, FINISH_FALLBACK_MS)
|
||||
const engine = this.engine
|
||||
if (engine === null) {
|
||||
this.deliverFinal(this.lastText)
|
||||
return
|
||||
}
|
||||
try {
|
||||
console.info(`MEIDA_ASR finish sid=${sid}`)
|
||||
engine.finish(sid)
|
||||
} catch (_e) {
|
||||
// 会话已被引擎收尾(VAD 自动完成等)后再 finish 会同步抛错:用已有结果兜底
|
||||
this.deliverFinal(this.lastText)
|
||||
}
|
||||
}
|
||||
|
||||
/** 上滑取消:丢弃本次识别,不回调结果。 */
|
||||
cancel(): void {
|
||||
if (!this.recording) {
|
||||
return
|
||||
}
|
||||
this.recording = false
|
||||
this.finalized = true
|
||||
this.lastText = ''
|
||||
const engine = this.engine
|
||||
if (engine !== null) {
|
||||
try {
|
||||
console.info(`MEIDA_ASR cancel sid=${this.sessionId}`)
|
||||
engine.cancel(this.sessionId)
|
||||
} catch (_e) {
|
||||
// finalized 已置位,晚到的结果不会上屏
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面销毁时释放引擎。 */
|
||||
release(): void {
|
||||
this.cancel()
|
||||
this.recording = false
|
||||
this.finalized = true
|
||||
const engine = this.engine
|
||||
this.engine = null
|
||||
if (engine !== null) {
|
||||
try {
|
||||
console.info('MEIDA_ASR shutdown on release')
|
||||
engine.shutdown()
|
||||
} catch (_e) {
|
||||
// 页面销毁路径,失败即随对象丢弃
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 建引擎;busy/销毁中(其他应用占用、刚 shutdown)等半秒重试一次。 */
|
||||
private static async createEngineWithRetry(): Promise<speechRecognizer.SpeechRecognitionEngine> {
|
||||
const extra: Record<string, Object> = { 'locate': 'CN', 'recognizerMode': 'short' }
|
||||
const params: speechRecognizer.CreateEngineParams = {
|
||||
language: 'zh-CN',
|
||||
online: 1,
|
||||
extraParams: extra
|
||||
}
|
||||
try {
|
||||
const engine = await speechRecognizer.createEngine(params)
|
||||
console.info('MEIDA_ASR engine created')
|
||||
return engine
|
||||
} catch (e) {
|
||||
console.error(`MEIDA_ASR createEngine failed, retry in ${RECREATE_WAIT_MS}ms: ${String(e)}`)
|
||||
await VoiceInputEngine.sleep(RECREATE_WAIT_MS)
|
||||
const engine = await speechRecognizer.createEngine(params)
|
||||
console.info('MEIDA_ASR engine created on retry')
|
||||
return engine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 引擎全系统单实例:上一会话未收尾或其他应用占用时 isBusy。
|
||||
* 等待空闲;超过 1.5s 判定卡死 → shutdown 重建(shutdown 后立即 create 会撞
|
||||
* 1002200008 销毁中窗口,重建内含半秒等待 + 重试)。
|
||||
*/
|
||||
private async waitEngineIdle(): Promise<void> {
|
||||
const engine = this.engine
|
||||
if (engine === null || !VoiceInputEngine.isEngineBusy(engine)) {
|
||||
return
|
||||
}
|
||||
console.warn('MEIDA_ASR engine busy, waiting idle...')
|
||||
const deadline: number = Date.now() + BUSY_WAIT_MAX_MS
|
||||
while (VoiceInputEngine.isEngineBusy(engine)) {
|
||||
if (Date.now() >= deadline) {
|
||||
console.warn('MEIDA_ASR engine stuck busy, recreate')
|
||||
try {
|
||||
engine.shutdown()
|
||||
} catch (_e) {
|
||||
// 忙/销毁中 shutdown 抛错:直接走重建
|
||||
}
|
||||
this.engine = await VoiceInputEngine.createEngineWithRetry()
|
||||
this.attachListener()
|
||||
return
|
||||
}
|
||||
await VoiceInputEngine.sleep(BUSY_WAIT_STEP_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private static isEngineBusy(engine: speechRecognizer.SpeechRecognitionEngine): boolean {
|
||||
try {
|
||||
return engine.isBusy()
|
||||
} catch (_e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static sleep(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve: () => void) => {
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
}, ms)
|
||||
})
|
||||
}
|
||||
|
||||
private attachListener(): void {
|
||||
const engine = this.engine
|
||||
if (engine === null) {
|
||||
return
|
||||
}
|
||||
engine.setListener({
|
||||
onStart: (sessionId: string, eventMessage: string) => {
|
||||
console.info(`MEIDA_ASR onStart sid=${sessionId}`)
|
||||
},
|
||||
onEvent: (sessionId: string, eventCode: number, eventMessage: string) => {
|
||||
console.info(`MEIDA_ASR onEvent sid=${sessionId} code=${eventCode}`)
|
||||
},
|
||||
onResult: (sessionId: string, result: speechRecognizer.SpeechRecognitionResult) => {
|
||||
if (result.result !== '') {
|
||||
this.lastText = result.result
|
||||
}
|
||||
if (this.finalized || sessionId !== this.sessionId) {
|
||||
return
|
||||
}
|
||||
if (this.callbacks !== null) {
|
||||
this.callbacks.onPartial(this.lastText)
|
||||
}
|
||||
if (result.isLast) {
|
||||
console.info(`MEIDA_ASR final(isLast) len=${this.lastText.length}`)
|
||||
this.deliverFinal(this.lastText)
|
||||
}
|
||||
},
|
||||
onComplete: (sessionId: string, eventMessage: string) => {
|
||||
console.info(`MEIDA_ASR onComplete sid=${sessionId}`)
|
||||
if (sessionId !== this.sessionId) {
|
||||
return
|
||||
}
|
||||
// 手指仍按着但引擎已收尾(VAD 判定说完/达到时长上限):提前交付,
|
||||
// 松手时 finish() 因 recording=false 空转,不会二次发送
|
||||
this.recording = false
|
||||
this.deliverFinal(this.lastText)
|
||||
},
|
||||
onError: (sessionId: string, errorCode: number, errorMessage: string) => {
|
||||
console.error(`MEIDA_ASR onError sid=${sessionId} code=${errorCode} msg=${errorMessage}`)
|
||||
if (sessionId !== this.sessionId) {
|
||||
return
|
||||
}
|
||||
this.recording = false
|
||||
const alreadyFinal: boolean = this.finalized
|
||||
this.finalized = true
|
||||
if (!alreadyFinal && this.callbacks !== null) {
|
||||
this.callbacks.onError(VoiceInputEngine.friendlyError(errorCode, errorMessage))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private deliverFinal(text: string): void {
|
||||
if (this.finalized) {
|
||||
return
|
||||
}
|
||||
this.finalized = true
|
||||
if (this.callbacks !== null) {
|
||||
this.callbacks.onFinal(text)
|
||||
}
|
||||
}
|
||||
|
||||
private static friendlyError(code: number, msg: string): string {
|
||||
if (code === 1002200006) {
|
||||
return '语音引擎正忙,请稍等 1 秒再试'
|
||||
}
|
||||
if (code === 1002200001) {
|
||||
return '当前设备不支持语音识别(缺少离线语音服务,请用真机测试)'
|
||||
}
|
||||
if (code === 1002200008) {
|
||||
return '语音引擎正在重启,请再试一次'
|
||||
}
|
||||
return `语音识别失败(${code}):${msg}`
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ interface AuthWireResp {
|
||||
const PREF_NAME = 'meida_auth'
|
||||
const KEY_TOKENS = 'tokens'
|
||||
const KEY_BASE_URL = 'base_url'
|
||||
export const DEFAULT_BASE_URL = 'http://127.0.0.1:8000'
|
||||
export const DEFAULT_BASE_URL = 'http://192.168.3.25:8666'
|
||||
|
||||
export class AuthStore {
|
||||
private static tokens: TokenPair | undefined
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
/**
|
||||
* ChatPage —— AI 穿搭对话页(F4)。
|
||||
*
|
||||
* 任务契约:docs/开发任务包/F4-AI对话页.md
|
||||
* - 消息列表 @State ChatMsg[]:本地内存态,退出即失(V1 不做会话持久化,
|
||||
* 后端 ag_conversation 会话落库属后续任务)
|
||||
* - 发送流程:push 用户消息 → AI 侧"正在思考…"占位 → POST /api/v1/agent/chat
|
||||
* → 成功替换占位(meta = "模型 · 耗时")并更新顶部配额薄条 quota_remaining
|
||||
* - 失败态:只 catch ApiError(40301 额度用尽 / 60003 今日服务上限 / 50000 网络错
|
||||
* 与未知异常,均已在 ApiClient 内包成 ApiError),统一在 AI 气泡位展示
|
||||
* message + hint 次行小字,"正在思考…"占位必撤
|
||||
* - 发送中禁用输入防连点;空消息不发送;首次进入插入本地静态欢迎语(不调接口)
|
||||
*/
|
||||
import { router } from '@kit.ArkUI'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
import { VoiceInputEngine } from '../components/f4/VoiceInputEngine'
|
||||
import { TtsEngine } from '../components/f4/TtsEngine'
|
||||
|
||||
/** wire 响应:POST /api/v1/agent/chat 的 data.usage(字段名对齐后端 JSON) */
|
||||
interface ChatUsage {
|
||||
tokens_in: number
|
||||
tokens_out: number
|
||||
@@ -24,7 +12,6 @@ interface ChatUsage {
|
||||
cost_cents: number
|
||||
}
|
||||
|
||||
/** wire 响应:POST /api/v1/agent/chat 的 data(字段名对齐后端 JSON) */
|
||||
interface ChatResp {
|
||||
reply: string
|
||||
provider: string
|
||||
@@ -36,7 +23,6 @@ interface ChatResp {
|
||||
quota_remaining: number
|
||||
}
|
||||
|
||||
/** 列表项:meta 存 AI 消息底部小字(成功为"模型 · 耗时",失败为 e.hint) */
|
||||
interface ChatMsg {
|
||||
role: 'user' | 'ai'
|
||||
text: string
|
||||
@@ -48,12 +34,14 @@ const ROLE_AI = 'ai'
|
||||
const THINKING_TEXT = '正在思考…'
|
||||
const WELCOME_TEXT = '你好,我是你的穿搭顾问,可以问我面试怎么穿、约会怎么搭~'
|
||||
|
||||
/** 单条消息气泡:用户右对齐主色底,AI 左对齐白底;meta 为底部小字 */
|
||||
@Component
|
||||
struct MessageBubble {
|
||||
@Prop role: string = ROLE_AI
|
||||
@Prop text: string = ''
|
||||
@Prop meta: string = ''
|
||||
@Prop isSpeaking: boolean = false
|
||||
@Prop msgIdx: number = -1
|
||||
speakHandler: (idx: number) => void = () => {}
|
||||
|
||||
private isUser(): boolean {
|
||||
return this.role === ROLE_USER
|
||||
@@ -66,12 +54,25 @@ struct MessageBubble {
|
||||
.fontSize(15)
|
||||
.lineHeight(22)
|
||||
.fontColor(this.isUser() ? '#FFFFFF' : '#333333')
|
||||
if (this.meta.length > 0) {
|
||||
Text(this.meta)
|
||||
.fontSize(11)
|
||||
.margin({ top: 4 })
|
||||
.fontColor(this.isUser() ? '#FFDCD8' : '#999999')
|
||||
Row() {
|
||||
if (this.meta.length > 0) {
|
||||
Text(this.meta)
|
||||
.fontSize(11)
|
||||
.fontColor(this.isUser() ? '#FFDCD8' : '#999999')
|
||||
}
|
||||
if (!this.isUser()) {
|
||||
Blank()
|
||||
Text(this.isSpeaking ? '🔊' : '🔈')
|
||||
.fontSize(14)
|
||||
.fontColor('#999999')
|
||||
.margin({ left: 4 })
|
||||
.onClick(() => {
|
||||
this.speakHandler(this.msgIdx)
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
|
||||
@@ -88,20 +89,81 @@ struct MessageBubble {
|
||||
@Entry
|
||||
@Component
|
||||
struct ChatPage {
|
||||
/** 本地内存态:不做会话持久化(后端 ag_conversation 属后续任务) */
|
||||
@State messages: ChatMsg[] = []
|
||||
@State inputText: string = ''
|
||||
@State sending: boolean = false
|
||||
/** -1 = 尚未从任何响应拿到配额 → 薄条不显示 */
|
||||
@State quotaRemaining: number = -1
|
||||
@State voiceMode: boolean = false
|
||||
@State holdState: string = ''
|
||||
@State partialText: string = ''
|
||||
@State voiceHint: string = ''
|
||||
@State speakingMsgIdx: number = -1
|
||||
private fingerDown: boolean = false
|
||||
private holdStartY: number = 0
|
||||
private voiceEngine: VoiceInputEngine = new VoiceInputEngine()
|
||||
private ttsEngine: TtsEngine = new TtsEngine()
|
||||
private scroller: Scroller = new Scroller()
|
||||
private autoSpeak: boolean = true
|
||||
|
||||
aboutToAppear(): void {
|
||||
const welcome: ChatMsg = { role: ROLE_AI, text: WELCOME_TEXT }
|
||||
this.messages.push(welcome)
|
||||
// 朗读结束(播完/被停/出错)→ 复位消息气泡的 🔊 高亮
|
||||
this.ttsEngine.setOnStateChanged((speaking: boolean) => {
|
||||
if (!speaking) {
|
||||
this.speakingMsgIdx = -1
|
||||
}
|
||||
})
|
||||
this.voiceEngine.setCallbacks({
|
||||
onPartial: (text: string): void => {
|
||||
this.partialText = text
|
||||
},
|
||||
onFinal: (text: string): void => {
|
||||
const finalText: string = text.trim()
|
||||
if (finalText.length === 0) {
|
||||
this.showVoiceHint('没听清,请再按住说一次')
|
||||
return
|
||||
}
|
||||
this.inputText = finalText
|
||||
this.send()
|
||||
},
|
||||
onError: (message: string): void => {
|
||||
this.showVoiceHint(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
this.voiceEngine.release()
|
||||
this.ttsEngine.release()
|
||||
}
|
||||
|
||||
private async speakText(text: string, msgIdx: number, manual: boolean): Promise<void> {
|
||||
if (this.speakingMsgIdx === msgIdx && this.ttsEngine.isSpeaking()) {
|
||||
this.ttsEngine.stop()
|
||||
this.speakingMsgIdx = -1
|
||||
return
|
||||
}
|
||||
this.speakingMsgIdx = msgIdx
|
||||
try {
|
||||
await this.ttsEngine.speak(text)
|
||||
} catch (e) {
|
||||
this.speakingMsgIdx = -1
|
||||
console.error(`MEIDA_TTS speak failed: ${String(e)}`)
|
||||
if (manual) {
|
||||
this.showVoiceHint('语音朗读启动失败(模拟器无语音服务,请用真机)')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onSpeakToggle(idx: number): void {
|
||||
if (idx < 0 || idx >= this.messages.length) {
|
||||
return
|
||||
}
|
||||
const msg: ChatMsg = this.messages[idx]
|
||||
this.speakText(msg.text, idx, true)
|
||||
}
|
||||
|
||||
/** 发送入口(回车/按钮共用):空消息不发,发送中防连点 */
|
||||
private send(): void {
|
||||
const text: string = this.inputText.trim()
|
||||
if (text.length === 0 || this.sending) {
|
||||
@@ -113,6 +175,8 @@ struct ChatPage {
|
||||
private async doSend(text: string): Promise<void> {
|
||||
this.inputText = ''
|
||||
this.sending = true
|
||||
this.ttsEngine.stop()
|
||||
this.speakingMsgIdx = -1
|
||||
const userMsg: ChatMsg = { role: ROLE_USER, text: text }
|
||||
this.messages.push(userMsg)
|
||||
const thinkingMsg: ChatMsg = { role: ROLE_AI, text: THINKING_TEXT }
|
||||
@@ -124,9 +188,11 @@ struct ChatPage {
|
||||
const meta: string = `${data.model} · ${data.usage.latency_ms}ms`
|
||||
this.replaceThinking(data.reply, meta)
|
||||
this.quotaRemaining = data.quota_remaining
|
||||
if (this.autoSpeak) {
|
||||
const aiIdx: number = this.messages.length - 1
|
||||
this.speakText(data.reply, aiIdx, false)
|
||||
}
|
||||
} catch (e) {
|
||||
// 只 catch ApiError:40301 / 60003 / 网络错(50000)均包成 ApiError,
|
||||
// 统一气泡位展示 message + hint;占位在 replaceThinking 里必撤
|
||||
if (e instanceof ApiError) {
|
||||
this.replaceThinking(e.message, e.hint ?? '')
|
||||
}
|
||||
@@ -135,7 +201,6 @@ struct ChatPage {
|
||||
}
|
||||
}
|
||||
|
||||
/** 把末尾"正在思考…"占位替换为结果;占位缺失时兜底补一条,确保占位一定被撤掉 */
|
||||
private replaceThinking(text: string, meta: string): void {
|
||||
const replaced: ChatMsg = { role: ROLE_AI, text: text, meta: meta }
|
||||
const idx: number = this.messages.length - 1
|
||||
@@ -153,91 +218,252 @@ struct ChatPage {
|
||||
}, 50)
|
||||
}
|
||||
|
||||
private showVoiceHint(message: string): void {
|
||||
this.voiceHint = message
|
||||
setTimeout(() => {
|
||||
if (this.voiceHint === message) {
|
||||
this.voiceHint = ''
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
private async startHold(): Promise<void> {
|
||||
this.fingerDown = true
|
||||
this.partialText = ''
|
||||
this.ttsEngine.stop()
|
||||
this.speakingMsgIdx = -1
|
||||
const granted = await VoiceInputEngine.ensureMicrophonePermission()
|
||||
if (!this.fingerDown) {
|
||||
return
|
||||
}
|
||||
if (!granted) {
|
||||
this.showVoiceHint('未获得麦克风权限,无法语音输入')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.voiceEngine.start()
|
||||
} catch (e) {
|
||||
console.error(`MEIDA_ASR startHold failed: ${String(e)}`)
|
||||
this.showVoiceHint('语音引擎启动失败,请重试')
|
||||
return
|
||||
}
|
||||
if (!this.fingerDown) {
|
||||
this.voiceEngine.cancel()
|
||||
return
|
||||
}
|
||||
this.holdState = 'rec'
|
||||
}
|
||||
|
||||
private endHold(wantCancel: boolean): void {
|
||||
this.fingerDown = false
|
||||
if (this.holdState === '') {
|
||||
return
|
||||
}
|
||||
this.holdState = ''
|
||||
this.partialText = ''
|
||||
if (wantCancel) {
|
||||
this.voiceEngine.cancel()
|
||||
} else {
|
||||
this.voiceEngine.finish()
|
||||
}
|
||||
}
|
||||
|
||||
private onHoldTouch(event: TouchEvent): void {
|
||||
if (event.type === TouchType.Down) {
|
||||
if (event.touches.length === 0) {
|
||||
return
|
||||
}
|
||||
this.holdStartY = event.touches[0].displayY
|
||||
this.startHold()
|
||||
} else if (event.type === TouchType.Move) {
|
||||
if (this.holdState === '' || event.touches.length === 0) {
|
||||
return
|
||||
}
|
||||
const dy: number = this.holdStartY - event.touches[0].displayY
|
||||
this.holdState = dy > 80 ? 'cancel' : 'rec'
|
||||
} else if (event.type === TouchType.Up) {
|
||||
this.endHold(this.holdState === 'cancel')
|
||||
} else if (event.type === TouchType.Cancel) {
|
||||
this.endHold(true)
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
VoiceOverlay() {
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#66000000')
|
||||
|
||||
Column({ space: 10 }) {
|
||||
Text(this.holdState === 'cancel' ? '✕' : '🎙')
|
||||
.fontSize(40)
|
||||
.fontColor(Color.White)
|
||||
Text(this.holdState === 'cancel' ? '松开 取消' : '松开 发送')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(Color.White)
|
||||
Text(this.holdState === 'cancel' ? '' : (this.partialText !== '' ? this.partialText : '正在聆听…'))
|
||||
.fontSize(13)
|
||||
.fontColor('#DDDDDD')
|
||||
.maxLines(2)
|
||||
.textAlign(TextAlign.Center)
|
||||
Text('↑ 上滑取消')
|
||||
.fontSize(11)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
.width('72%')
|
||||
.padding({ top: 24, bottom: 24, left: 16, right: 16 })
|
||||
.borderRadius(20)
|
||||
.backgroundColor('#CC1F1F1F')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 顶部栏:返回 + 标题
|
||||
Row() {
|
||||
Text('‹')
|
||||
.fontSize(26)
|
||||
.fontColor('#333333')
|
||||
.width(32)
|
||||
.height(32)
|
||||
.textAlign(TextAlign.Center)
|
||||
.onClick(() => {
|
||||
router.back()
|
||||
})
|
||||
Text('AI 穿搭顾问')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#1F1F1F')
|
||||
.margin({ left: 4 })
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
.height(56)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor('#FFFFFF')
|
||||
|
||||
// 配额薄条:拿到首个响应后展示"今日剩余 N 次"
|
||||
if (this.quotaRemaining >= 0) {
|
||||
Text(`今日剩余 ${this.quotaRemaining} 次`)
|
||||
.fontSize(12)
|
||||
.fontColor('#B3554D')
|
||||
.backgroundColor('#FBEAE8')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
}
|
||||
|
||||
// 消息列表(key 带内容:占位被替换时 key 变化触发该项重渲染)
|
||||
List({ scroller: this.scroller }) {
|
||||
ForEach(this.messages, (msg: ChatMsg, idx: number) => {
|
||||
ListItem() {
|
||||
MessageBubble({
|
||||
role: msg.role,
|
||||
text: msg.text,
|
||||
meta: msg.meta ?? ''
|
||||
Stack() {
|
||||
Column() {
|
||||
Row() {
|
||||
Text('‹')
|
||||
.fontSize(26)
|
||||
.fontColor('#333333')
|
||||
.width(32)
|
||||
.height(32)
|
||||
.textAlign(TextAlign.Center)
|
||||
.onClick(() => {
|
||||
router.back()
|
||||
})
|
||||
}
|
||||
}, (msg: ChatMsg, idx: number) => `${idx}_${msg.role}_${msg.text}`)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
.backgroundColor('#F5F5F7')
|
||||
.scrollBar(BarState.Off)
|
||||
Text('AI 穿搭顾问')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#1F1F1F')
|
||||
.margin({ left: 4 })
|
||||
Blank()
|
||||
Text(this.autoSpeak ? '🔊' : '🔇')
|
||||
.fontSize(18)
|
||||
.fontColor('#666666')
|
||||
.width(32)
|
||||
.height(32)
|
||||
.textAlign(TextAlign.Center)
|
||||
.onClick(() => {
|
||||
this.autoSpeak = !this.autoSpeak
|
||||
if (!this.autoSpeak) {
|
||||
this.ttsEngine.stop()
|
||||
this.speakingMsgIdx = -1
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height(56)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor('#FFFFFF')
|
||||
|
||||
// 底部输入栏:发送中禁用输入框与按钮防连点
|
||||
Row() {
|
||||
TextInput({
|
||||
text: this.inputText,
|
||||
placeholder: this.sending ? 'AI 正在回复…' : '聊聊穿搭,比如:面试怎么穿?'
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor('#F2F2F4')
|
||||
.borderRadius(8)
|
||||
.enabled(!this.sending)
|
||||
.enterKeyType(EnterKeyType.Send)
|
||||
.onChange((value: string) => {
|
||||
this.inputText = value
|
||||
})
|
||||
.onSubmit(() => {
|
||||
this.send()
|
||||
})
|
||||
Button(this.sending ? '回复中' : '发送')
|
||||
.fontSize(14)
|
||||
.height(36)
|
||||
.margin({ left: 8 })
|
||||
.backgroundColor(this.sending ? '#C9C9CD' : '#D9605A')
|
||||
.enabled(!this.sending)
|
||||
.onClick(() => {
|
||||
this.send()
|
||||
})
|
||||
if (this.quotaRemaining >= 0) {
|
||||
Text(`今日剩余 ${this.quotaRemaining} 次`)
|
||||
.fontSize(12)
|
||||
.fontColor('#B3554D')
|
||||
.backgroundColor('#FBEAE8')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
}
|
||||
|
||||
List({ scroller: this.scroller }) {
|
||||
ForEach(this.messages, (msg: ChatMsg, idx: number) => {
|
||||
ListItem() {
|
||||
MessageBubble({
|
||||
role: msg.role,
|
||||
text: msg.text,
|
||||
meta: msg.meta ?? '',
|
||||
isSpeaking: this.speakingMsgIdx === idx,
|
||||
msgIdx: idx as number,
|
||||
speakHandler: (i: number) => {
|
||||
this.onSpeakToggle(i)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, (msg: ChatMsg, idx: number) => `${idx}_${msg.role}_${msg.text}`)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
.backgroundColor('#F5F5F7')
|
||||
.scrollBar(BarState.Off)
|
||||
|
||||
if (this.voiceHint !== '') {
|
||||
Text(this.voiceHint)
|
||||
.fontSize(12)
|
||||
.fontColor('#C0392B')
|
||||
.backgroundColor('#FDECEC')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
}
|
||||
|
||||
Row() {
|
||||
Text(this.voiceMode ? '⌨' : '🎤')
|
||||
.fontSize(20)
|
||||
.fontColor(this.voiceMode ? '#D9605A' : '#666666')
|
||||
.width(36)
|
||||
.height(40)
|
||||
.textAlign(TextAlign.Center)
|
||||
.align(Alignment.Center)
|
||||
.onClick(() => {
|
||||
if (this.holdState !== '') {
|
||||
this.endHold(true)
|
||||
}
|
||||
this.voiceMode = !this.voiceMode
|
||||
})
|
||||
|
||||
if (this.voiceMode) {
|
||||
Text(this.holdState === 'cancel' ? '松开 取消' : (this.holdState === 'rec' ? '松开 发送' : '按住 说话'))
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.textAlign(TextAlign.Center)
|
||||
.fontColor(this.holdState === 'cancel' ? '#C0392B' : '#333333')
|
||||
.backgroundColor(this.holdState === 'cancel' ? '#FDECEC' : '#F2F2F4')
|
||||
.borderRadius(8)
|
||||
.onTouch((event: TouchEvent) => {
|
||||
this.onHoldTouch(event)
|
||||
})
|
||||
} else {
|
||||
TextInput({
|
||||
text: this.inputText,
|
||||
placeholder: this.sending ? 'AI 正在回复…' : '聊聊穿搭,比如:面试怎么穿?'
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.height(40)
|
||||
.fontSize(14)
|
||||
.backgroundColor('#F2F2F4')
|
||||
.borderRadius(8)
|
||||
.enabled(!this.sending)
|
||||
.enterKeyType(EnterKeyType.Send)
|
||||
.onChange((value: string) => {
|
||||
this.inputText = value
|
||||
})
|
||||
.onSubmit(() => {
|
||||
this.send()
|
||||
})
|
||||
Button(this.sending ? '回复中' : '发送')
|
||||
.fontSize(14)
|
||||
.height(36)
|
||||
.margin({ left: 8 })
|
||||
.backgroundColor(this.sending ? '#C9C9CD' : '#D9605A')
|
||||
.enabled(!this.sending)
|
||||
.onClick(() => {
|
||||
this.send()
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
.backgroundColor('#FFFFFF')
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
|
||||
.backgroundColor('#FFFFFF')
|
||||
.height('100%')
|
||||
|
||||
if (this.holdState !== '') {
|
||||
this.VoiceOverlay()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
*
|
||||
* 协作铁律(FE 批次 README §2/§3):
|
||||
* - 页面跳转一律 router.pushUrl({ url: 'pages/XxxPage' }),禁止 import 其它页面文件;
|
||||
* Tab2/3/4 本批用「占位 + 跳页」方案(目标页面是独立路由页,由对应任务实现)
|
||||
* Tab2/3 用「占位 + 跳页」方案;Tab4(我的)内嵌 components/f1/ProfileContent 组件
|
||||
* (组件级复用允许 import,独立路由页无底部 Tab 会把用户锁死在页面里)
|
||||
* - 请求只走 ApiClient;今日额度失败静默降级为 '—'(首页不打扰用户,不弹错误态)
|
||||
* - 响应 wire 类型用具名 interface(字段对齐后端 JSON);泛型调用显式类型参数
|
||||
*/
|
||||
|
||||
import { router } from '@kit.ArkUI'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ProfileContent } from '../components/f1/ProfileContent'
|
||||
|
||||
/** GET /api/v1/quota?quota_key=agent_chat 的响应(字段名对齐后端 JSON)。 */
|
||||
interface QuotaWireResp {
|
||||
@@ -229,7 +231,9 @@ struct HomePage {
|
||||
.tabBar(this.TabBarItem('✦', 'AI', 2))
|
||||
|
||||
TabContent() {
|
||||
this.PlaceholderTab('☺', '我的', '个人资料与设置(建设中)', '去我的资料', 'pages/ProfilePage')
|
||||
// 个人中心内容直接内嵌(不再「占位+跳页」:独立路由页无底部 Tab,
|
||||
// 进去后只能登出,用户被锁死在页面里)
|
||||
ProfileContent()
|
||||
}
|
||||
.tabBar(this.TabBarItem('☺', '我的', 3))
|
||||
}
|
||||
|
||||
@@ -1,524 +1,36 @@
|
||||
/**
|
||||
* ProfilePage(F1)—— 个人中心:用户脱敏 ID、登出、配额卡片、最近流水。
|
||||
* ProfilePage —— 个人中心路由壳。
|
||||
*
|
||||
* 数据契约(全部走 ApiClient/AuthStore,不直接碰 http):
|
||||
* - 配额:GET /api/v1/quota?quota_key=agent_chat|tryon|report_basic(并行拉取)
|
||||
* - 流水:GET /api/v1/quota/ledger?limit=10
|
||||
* 周期文案按架构文档 §7.4 配额映射:agent_chat/tryon=日、report_basic=终身一次。
|
||||
*
|
||||
* ArkTS 约束:wire 用具名 interface、UI 状态用带默认值的 class + new、
|
||||
* 未知异常包 ApiError、async 全部放具名方法、显式泛型参数。
|
||||
* 内容已抽到 components/f1/ProfileContent(同时被 HomePage Tab4 内嵌),
|
||||
* 本页仅保留独立路由入口(main_pages 已注册,供深链/外部跳转),
|
||||
* 未登录时先跳登录页再进入。
|
||||
*/
|
||||
|
||||
import router from '@ohos.router'
|
||||
import { ApiClient } from '../core/ApiClient'
|
||||
import { ApiError } from '../core/ApiTypes'
|
||||
import { AuthStore } from '../core/AuthStore'
|
||||
import { ErrorBanner } from '../components/f1/ErrorBanner'
|
||||
import { QuotaCardView } from '../components/f1/QuotaCard'
|
||||
import { LedgerItemView } from '../components/f1/LedgerItem'
|
||||
|
||||
/** GET /api/v1/quota 响应(字段名对齐后端 JSON)。 */
|
||||
interface QuotaWire {
|
||||
quota_key: string
|
||||
used: number
|
||||
limit_value: number
|
||||
bonus_granted: number
|
||||
remaining: number
|
||||
reset_at: string | null
|
||||
}
|
||||
|
||||
/** GET /api/v1/quota/ledger 数组元素。 */
|
||||
interface LedgerWire {
|
||||
tx_id: string
|
||||
delta: number
|
||||
tx_type: string
|
||||
status: string
|
||||
quota_key: string
|
||||
balance_after: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 配额卡片的 UI 状态模型。 */
|
||||
class QuotaCardData {
|
||||
title: string
|
||||
used: number = 0
|
||||
total: number = 0
|
||||
remaining: number = 0
|
||||
resetText: string = ''
|
||||
loaded: boolean = false
|
||||
errorText: string = ''
|
||||
|
||||
constructor(title: string) {
|
||||
this.title = title
|
||||
}
|
||||
}
|
||||
|
||||
/** 流水行的 UI 状态模型(映射/配色在构造期完成)。 */
|
||||
class LedgerRowData {
|
||||
typeText: string = ''
|
||||
statusText: string = ''
|
||||
statusColor: string = '#999999'
|
||||
statusBg: string = '#F4F4F4'
|
||||
quotaText: string = ''
|
||||
timeText: string = ''
|
||||
deltaText: string = ''
|
||||
deltaColor: string = '#666666'
|
||||
balanceText: string = ''
|
||||
}
|
||||
|
||||
const COLOR_POSITIVE = '#2E9E5B'
|
||||
const COLOR_NEGATIVE = '#C0392B'
|
||||
const COLOR_MUTED = '#8A8A8A'
|
||||
|
||||
/** quota_key → 中文名(对齐产品方案 §3.2 / 架构文档 §7.4)。 */
|
||||
function quotaName(key: string): string {
|
||||
if (key === 'agent_chat') {
|
||||
return 'AI 对话'
|
||||
}
|
||||
if (key === 'tryon') {
|
||||
return '换装试穿'
|
||||
}
|
||||
if (key === 'hairstyle') {
|
||||
return '发型试戴'
|
||||
}
|
||||
if (key === 'hd_export') {
|
||||
return '高清导出'
|
||||
}
|
||||
if (key === 'report_basic') {
|
||||
return '美学报告'
|
||||
}
|
||||
if (key === 'report_regen') {
|
||||
return '报告重新生成'
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
/** 是否日配额(决定「明日恢复」文案)。 */
|
||||
function isDailyQuota(key: string): boolean {
|
||||
return key === 'agent_chat' || key === 'tryon' || key === 'hairstyle' || key === 'hd_export'
|
||||
}
|
||||
|
||||
/** 周期文案:日配额=明日恢复;月/终身有 reset_at 显示日期;null 按周期区分。 */
|
||||
function resetTextFor(key: string, resetAt: string | null): string {
|
||||
if (resetAt === null || resetAt === '') {
|
||||
if (key === 'report_basic') {
|
||||
return '一次性额度'
|
||||
}
|
||||
if (key === 'report_regen') {
|
||||
return '每月重置'
|
||||
}
|
||||
return '每日重置'
|
||||
}
|
||||
if (isDailyQuota(key)) {
|
||||
return '明日恢复'
|
||||
}
|
||||
const short = resetAt.length >= 10 ? resetAt.substring(5, 10) : resetAt
|
||||
return `${short} 重置`
|
||||
}
|
||||
|
||||
/** tx_type → 中文(V1 三种,V2 预留 purchase/gift)。 */
|
||||
function txTypeText(t: string): string {
|
||||
if (t === 'consume') {
|
||||
return '消耗'
|
||||
}
|
||||
if (t === 'refund') {
|
||||
return '退还'
|
||||
}
|
||||
if (t === 'system_grant') {
|
||||
return '奖励'
|
||||
}
|
||||
if (t === 'purchase') {
|
||||
return '购买'
|
||||
}
|
||||
if (t === 'gift') {
|
||||
return '赠送'
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/** status → 中文(预扣三段式:reserved → committed;退还写反向流水)。 */
|
||||
function statusTextOf(s: string): string {
|
||||
if (s === 'reserved') {
|
||||
return '进行中'
|
||||
}
|
||||
if (s === 'committed') {
|
||||
return '已入账'
|
||||
}
|
||||
if (s === 'refunded') {
|
||||
return '已退还'
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function statusColorOf(s: string): string {
|
||||
if (s === 'reserved') {
|
||||
return '#B9770E'
|
||||
}
|
||||
if (s === 'committed') {
|
||||
return COLOR_POSITIVE
|
||||
}
|
||||
if (s === 'refunded') {
|
||||
return COLOR_MUTED
|
||||
}
|
||||
return '#999999'
|
||||
}
|
||||
|
||||
function statusBgOf(s: string): string {
|
||||
if (s === 'reserved') {
|
||||
return '#FBF0DC'
|
||||
}
|
||||
if (s === 'committed') {
|
||||
return '#E4F3EA'
|
||||
}
|
||||
return '#F4F4F4'
|
||||
}
|
||||
|
||||
/** ISO 时间 → 'MM-DD HH:mm'。 */
|
||||
function timeText(iso: string): string {
|
||||
if (iso === '') {
|
||||
return '—'
|
||||
}
|
||||
const t = iso.replace('T', ' ')
|
||||
return t.length >= 16 ? t.substring(5, 16) : t
|
||||
}
|
||||
|
||||
/** 用户 ID 脱敏:前 6 后 4。 */
|
||||
function maskUserId(id: string): string {
|
||||
if (id === '') {
|
||||
return '未登录'
|
||||
}
|
||||
if (id.length <= 10) {
|
||||
return id
|
||||
}
|
||||
return `${id.substring(0, 6)}…${id.substring(id.length - 4)}`
|
||||
}
|
||||
|
||||
/** wire → 行视图模型(对象字面量禁裸用,故用 class + 字段赋值)。 */
|
||||
function toLedgerRow(w: LedgerWire): LedgerRowData {
|
||||
const row = new LedgerRowData()
|
||||
row.typeText = txTypeText(w.tx_type)
|
||||
row.statusText = statusTextOf(w.status)
|
||||
row.statusColor = statusColorOf(w.status)
|
||||
row.statusBg = statusBgOf(w.status)
|
||||
row.quotaText = quotaName(w.quota_key)
|
||||
row.timeText = timeText(w.created_at)
|
||||
row.deltaText = w.delta > 0 ? `+${w.delta}` : `${w.delta}`
|
||||
row.deltaColor = w.delta > 0 ? COLOR_POSITIVE : (w.delta < 0 ? COLOR_NEGATIVE : COLOR_MUTED)
|
||||
row.balanceText = `余额 ${w.balance_after}`
|
||||
return row
|
||||
}
|
||||
|
||||
function toApiError(e: Object): ApiError {
|
||||
if (e instanceof ApiError) {
|
||||
return e
|
||||
}
|
||||
return new ApiError(50000, '请求失败,请检查网络', '请稍后重试')
|
||||
}
|
||||
import { ProfileContent } from '../components/f1/ProfileContent'
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct ProfilePage {
|
||||
@State userIdMasked: string = ''
|
||||
@State cardChat: QuotaCardData = new QuotaCardData('AI 对话')
|
||||
@State cardTryon: QuotaCardData = new QuotaCardData('换装试穿')
|
||||
@State cardReport: QuotaCardData = new QuotaCardData('美学报告')
|
||||
@State ledgerRows: LedgerRowData[] = []
|
||||
@State ledgerLoaded: boolean = false
|
||||
@State ledgerError: string = ''
|
||||
@State bannerMessage: string = ''
|
||||
@State bannerHint: string = ''
|
||||
@State refreshing: boolean = false
|
||||
@State loggingOut: boolean = false
|
||||
private pendingLoads: number = 0
|
||||
@State ready: boolean = false
|
||||
|
||||
aboutToAppear(): void {
|
||||
if (!AuthStore.isLoggedIn()) {
|
||||
router.replaceUrl({ url: 'pages/LoginPage' })
|
||||
return
|
||||
}
|
||||
const uid = AuthStore.getUserId()
|
||||
this.userIdMasked = maskUserId(uid !== undefined ? uid : '')
|
||||
this.startLoadAll()
|
||||
}
|
||||
|
||||
/** 并行拉三张配额卡 + 流水;计数归零时收起下拉刷新。 */
|
||||
private startLoadAll(): void {
|
||||
this.bannerMessage = ''
|
||||
this.bannerHint = ''
|
||||
this.ledgerError = ''
|
||||
this.cardChat = new QuotaCardData('AI 对话')
|
||||
this.cardTryon = new QuotaCardData('换装试穿')
|
||||
this.cardReport = new QuotaCardData('美学报告')
|
||||
this.pendingLoads = 4
|
||||
this.loadQuota('agent_chat')
|
||||
this.loadQuota('tryon')
|
||||
this.loadQuota('report_basic')
|
||||
this.loadLedger()
|
||||
}
|
||||
|
||||
private finishOne(): void {
|
||||
if (this.pendingLoads > 0) {
|
||||
this.pendingLoads -= 1
|
||||
}
|
||||
if (this.pendingLoads === 0) {
|
||||
this.refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
private async loadQuota(key: string): Promise<void> {
|
||||
try {
|
||||
const q = await ApiClient.get<QuotaWire>(`/api/v1/quota?quota_key=${key}`)
|
||||
const data = new QuotaCardData(quotaName(key))
|
||||
data.used = q.used
|
||||
data.total = q.limit_value + q.bonus_granted
|
||||
data.remaining = q.remaining
|
||||
data.resetText = resetTextFor(key, q.reset_at)
|
||||
data.loaded = true
|
||||
this.applyQuota(key, data)
|
||||
} catch (e) {
|
||||
// 单卡失败不拖垮整页:错误落在卡片内
|
||||
const err = toApiError(e)
|
||||
const data = new QuotaCardData(quotaName(key))
|
||||
data.loaded = true
|
||||
data.errorText = err.message
|
||||
this.applyQuota(key, data)
|
||||
} finally {
|
||||
this.finishOne()
|
||||
}
|
||||
}
|
||||
|
||||
private applyQuota(key: string, data: QuotaCardData): void {
|
||||
if (key === 'agent_chat') {
|
||||
this.cardChat = data
|
||||
} else if (key === 'tryon') {
|
||||
this.cardTryon = data
|
||||
} else {
|
||||
this.cardReport = data
|
||||
}
|
||||
}
|
||||
|
||||
private async loadLedger(): Promise<void> {
|
||||
try {
|
||||
const list = await ApiClient.get<LedgerWire[]>('/api/v1/quota/ledger?limit=10')
|
||||
const rows: LedgerRowData[] = []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
rows.push(toLedgerRow(list[i]))
|
||||
}
|
||||
this.ledgerRows = rows
|
||||
this.ledgerLoaded = true
|
||||
this.ledgerError = ''
|
||||
} catch (e) {
|
||||
const err = toApiError(e)
|
||||
this.ledgerLoaded = true
|
||||
this.ledgerError = err.hint !== undefined && err.hint !== '' ? `${err.message}(${err.hint})` : err.message
|
||||
} finally {
|
||||
this.finishOne()
|
||||
}
|
||||
}
|
||||
|
||||
private confirmLogout(): void {
|
||||
AlertDialog.show({
|
||||
title: '确认登出',
|
||||
message: '登出后本地会话将清除,配额与报告数据仍保留在账号下。',
|
||||
primaryButton: {
|
||||
value: '取消',
|
||||
action: () => {
|
||||
// 仅关闭弹窗
|
||||
}
|
||||
},
|
||||
secondaryButton: {
|
||||
value: '登出',
|
||||
fontColor: '#C0392B',
|
||||
action: () => this.performLogout()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async performLogout(): Promise<void> {
|
||||
if (this.loggingOut) {
|
||||
return
|
||||
}
|
||||
this.loggingOut = true
|
||||
try {
|
||||
await AuthStore.logout()
|
||||
} finally {
|
||||
this.loggingOut = false
|
||||
router.replaceUrl({ url: 'pages/LoginPage' })
|
||||
}
|
||||
this.ready = true
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Refresh({ refreshing: $$this.refreshing }) {
|
||||
Scroll() {
|
||||
Column({ space: 14 }) {
|
||||
// 顶部:用户信息 + 登出
|
||||
Row({ space: 12 }) {
|
||||
Column() {
|
||||
Text('美')
|
||||
.fontSize(24)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.width(56)
|
||||
.height(56)
|
||||
.borderRadius(28)
|
||||
.backgroundColor('#C2185B')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text('美搭用户')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#2B2B2B')
|
||||
Text(`ID ${this.userIdMasked}`)
|
||||
.fontSize(12)
|
||||
.fontColor('#9A9A9A')
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
|
||||
Button(this.loggingOut ? '登出中…' : '登出')
|
||||
.fontSize(13)
|
||||
.height(32)
|
||||
.borderRadius(16)
|
||||
.padding({ left: 14, right: 14 })
|
||||
.backgroundColor('#F7E3EA')
|
||||
.fontColor('#C2185B')
|
||||
.enabled(!this.loggingOut)
|
||||
.onClick(() => this.confirmLogout())
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(16)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
ErrorBanner({
|
||||
message: this.bannerMessage,
|
||||
hint: this.bannerHint,
|
||||
onClose: () => {
|
||||
this.bannerMessage = ''
|
||||
this.bannerHint = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 配额三卡
|
||||
Row({ space: 10 }) {
|
||||
QuotaCardView({
|
||||
title: this.cardChat.title,
|
||||
remaining: this.cardChat.remaining,
|
||||
used: this.cardChat.used,
|
||||
total: this.cardChat.total,
|
||||
resetText: this.cardChat.resetText,
|
||||
loaded: this.cardChat.loaded,
|
||||
errorText: this.cardChat.errorText,
|
||||
accent: '#C2185B'
|
||||
})
|
||||
.layoutWeight(1)
|
||||
|
||||
QuotaCardView({
|
||||
title: this.cardTryon.title,
|
||||
remaining: this.cardTryon.remaining,
|
||||
used: this.cardTryon.used,
|
||||
total: this.cardTryon.total,
|
||||
resetText: this.cardTryon.resetText,
|
||||
loaded: this.cardTryon.loaded,
|
||||
errorText: this.cardTryon.errorText,
|
||||
accent: '#7A5AF8'
|
||||
})
|
||||
.layoutWeight(1)
|
||||
|
||||
QuotaCardView({
|
||||
title: this.cardReport.title,
|
||||
remaining: this.cardReport.remaining,
|
||||
used: this.cardReport.used,
|
||||
total: this.cardReport.total,
|
||||
resetText: this.cardReport.resetText,
|
||||
loaded: this.cardReport.loaded,
|
||||
errorText: this.cardReport.errorText,
|
||||
accent: '#2E9E5B'
|
||||
})
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
|
||||
// 最近流水
|
||||
Column({ space: 4 }) {
|
||||
Text('最近流水')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#2B2B2B')
|
||||
.width('100%')
|
||||
|
||||
if (!this.ledgerLoaded) {
|
||||
Text('加载中…')
|
||||
.fontSize(13)
|
||||
.fontColor('#BBBBBB')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 24, bottom: 24 })
|
||||
} else if (this.ledgerError !== '') {
|
||||
Text(this.ledgerError)
|
||||
.fontSize(13)
|
||||
.fontColor('#C0392B')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 24, bottom: 24 })
|
||||
} else if (this.ledgerRows.length === 0) {
|
||||
Text('暂无流水记录')
|
||||
.fontSize(13)
|
||||
.fontColor('#BBBBBB')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 24, bottom: 24 })
|
||||
} else {
|
||||
ForEach(this.ledgerRows, (row: LedgerRowData) => {
|
||||
LedgerItemView({
|
||||
typeText: row.typeText,
|
||||
statusText: row.statusText,
|
||||
statusColor: row.statusColor,
|
||||
statusBg: row.statusBg,
|
||||
quotaText: row.quotaText,
|
||||
timeText: row.timeText,
|
||||
deltaText: row.deltaText,
|
||||
deltaColor: row.deltaColor,
|
||||
balanceText: row.balanceText
|
||||
})
|
||||
}, (row: LedgerRowData) => `${row.typeText}-${row.quotaText}-${row.timeText}`)
|
||||
}
|
||||
|
||||
if (this.ledgerLoaded && this.ledgerRows.length > 0) {
|
||||
Text('仅展示最近 10 条,下拉可刷新')
|
||||
.fontSize(11)
|
||||
.fontColor('#B3B3B3')
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(16)
|
||||
.backgroundColor('#FFFFFF')
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 14, bottom: 32 })
|
||||
}
|
||||
.scrollBar(BarState.Off)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
if (this.ready) {
|
||||
ProfileContent()
|
||||
} else {
|
||||
Column()
|
||||
.width('100%')
|
||||
}
|
||||
.onRefreshing(() => {
|
||||
this.startLoadAll()
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#F6F2F3')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#F6F2F3')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* ReportCreatePage —— 报告创建(F3):问卷六字段 + 模拟照片键 + 提交 + 任务轮询。
|
||||
*
|
||||
* 流程:
|
||||
* 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验
|
||||
* 1) 问卷表单(性别/身高/体重/生日/核心诉求多选/心理风格九型),提交前前端校验;
|
||||
* 身高/体重为滚轮选择器(showTextPickerDialog),不提供键盘输入
|
||||
* 2) 照片键为模拟:V1 骨架 COS 直传未接入,留空自动生成 report_photo/{userId}/{随机}.jpg
|
||||
* 3) POST /api/v1/reports(request_key 随机 hex;失败重试沿用同一 key,服务端幂等重放)
|
||||
* 4) 40920(本期已生成)→ 页内对话框:可跳最近一次报告(AppStorage 记录),
|
||||
@@ -24,6 +25,25 @@ const GOAL_OPTIONS: string[] = ['显高', '显瘦', '显白', '显气质', '遮
|
||||
const STYLE_OPTIONS: string[] = ['戏剧', '睿智', '自然', '古典', '优雅', '浪漫', '前卫', '少女', '少年']
|
||||
const LAST_REPORT_STORAGE_KEY: string = 'f3_last_report_id'
|
||||
|
||||
// 身高/体重改为选择器(不允许键盘输入):数值范围与原校验一致
|
||||
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 = 52
|
||||
|
||||
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)
|
||||
|
||||
interface FieldLabelParams {
|
||||
label: string
|
||||
err: string
|
||||
@@ -66,23 +86,18 @@ struct ReportCreatePage {
|
||||
}
|
||||
}
|
||||
|
||||
private isNumeric(s: string): boolean {
|
||||
return /^\d+(\.\d+)?$/.test(s.trim())
|
||||
}
|
||||
|
||||
private validateForm(): boolean {
|
||||
let ok = true
|
||||
this.genderErr = this.gender === '' ? '请选择性别' : ''
|
||||
if (this.genderErr !== '') {
|
||||
ok = false
|
||||
}
|
||||
const h = Number(this.heightInput)
|
||||
this.heightErr = !this.isNumeric(this.heightInput) || h < 100 || h > 250 ? '需为 100~250' : ''
|
||||
// 选择器产出必然在合法区间,未选才是唯一错误态
|
||||
this.heightErr = this.heightInput === '' ? '请选择身高' : ''
|
||||
if (this.heightErr !== '') {
|
||||
ok = false
|
||||
}
|
||||
const w = Number(this.weightInput)
|
||||
this.weightErr = !this.isNumeric(this.weightInput) || w < 25 || w > 300 ? '需为 25~300' : ''
|
||||
this.weightErr = this.weightInput === '' ? '请选择体重' : ''
|
||||
if (this.weightErr !== '') {
|
||||
ok = false
|
||||
}
|
||||
@@ -111,6 +126,46 @@ struct ReportCreatePage {
|
||||
return this.coreGoals.indexOf(g) >= 0
|
||||
}
|
||||
|
||||
/** TextPickerResult.index 是联合类型(多列选择器为数组),单列取数字下标。 */
|
||||
private pickIndex(v: TextPickerResult): number {
|
||||
return typeof v.index === 'number' ? v.index : -1
|
||||
}
|
||||
|
||||
/** 已有值从当前值滚起,未选过从常见默认值(165cm)滚起。 */
|
||||
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 = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private onSubmitTap(): void {
|
||||
if (this.submitting) {
|
||||
return
|
||||
@@ -349,16 +404,23 @@ struct ReportCreatePage {
|
||||
Row({ space: 12 }) {
|
||||
Column({ space: 8 }) {
|
||||
this.FieldLabel({ label: '身高 (cm)', err: this.heightErr, required: true })
|
||||
TextInput({ placeholder: '如 165', text: this.heightInput })
|
||||
.type(InputType.Number)
|
||||
.height(44)
|
||||
.fontSize(14)
|
||||
.backgroundColor('#F5F6F8')
|
||||
.borderRadius(10)
|
||||
.onChange((v: string) => {
|
||||
this.heightInput = v
|
||||
this.heightErr = ''
|
||||
})
|
||||
Row() {
|
||||
Text(this.heightInput === '' ? '请选择' : `${this.heightInput} cm`)
|
||||
.fontSize(14)
|
||||
.fontColor(this.heightInput === '' ? '#AAAAAA' : '#333333')
|
||||
.layoutWeight(1)
|
||||
Text('▼')
|
||||
.fontSize(10)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
.height(44)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor('#F5F6F8')
|
||||
.borderRadius(10)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.openHeightPicker()
|
||||
})
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
@@ -366,16 +428,23 @@ struct ReportCreatePage {
|
||||
|
||||
Column({ space: 8 }) {
|
||||
this.FieldLabel({ label: '体重 (kg)', err: this.weightErr, required: true })
|
||||
TextInput({ placeholder: '如 52', text: this.weightInput })
|
||||
.type(InputType.Number)
|
||||
.height(44)
|
||||
.fontSize(14)
|
||||
.backgroundColor('#F5F6F8')
|
||||
.borderRadius(10)
|
||||
.onChange((v: string) => {
|
||||
this.weightInput = v
|
||||
this.weightErr = ''
|
||||
})
|
||||
Row() {
|
||||
Text(this.weightInput === '' ? '请选择' : `${this.weightInput} kg`)
|
||||
.fontSize(14)
|
||||
.fontColor(this.weightInput === '' ? '#AAAAAA' : '#333333')
|
||||
.layoutWeight(1)
|
||||
Text('▼')
|
||||
.fontSize(10)
|
||||
.fontColor('#999999')
|
||||
}
|
||||
.height(44)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor('#F5F6F8')
|
||||
.borderRadius(10)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.openWeightPicker()
|
||||
})
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
@@ -9,7 +9,17 @@
|
||||
],
|
||||
"deliveryWithInstall": true,
|
||||
"requestPermissions": [
|
||||
{ "name": "ohos.permission.INTERNET" }
|
||||
{ "name": "ohos.permission.INTERNET" },
|
||||
{
|
||||
"name": "ohos.permission.MICROPHONE",
|
||||
"reason": "$string:microphone_permission_reason",
|
||||
"usedScene": {
|
||||
"abilities": [
|
||||
"EntryAbility"
|
||||
],
|
||||
"when": "inuse"
|
||||
}
|
||||
}
|
||||
],
|
||||
"installationFree": false,
|
||||
"pages": "$profile:main_pages",
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
},
|
||||
{
|
||||
"name": "EntryAbility_label",
|
||||
"value": "label"
|
||||
"value": "美搭"
|
||||
},
|
||||
{
|
||||
"name": "microphone_permission_reason",
|
||||
"value": "语音输入需要使用麦克风"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 700 B |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,35 @@
|
||||
/* eslint-env node */
|
||||
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/eslint-config-typescript',
|
||||
'@vue/eslint-config-prettier/skip-formatting',
|
||||
'vue-global-api',
|
||||
// 'plugin:import/errors',
|
||||
'plugin:import/warnings',
|
||||
'plugin:import/typescript'
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest'
|
||||
},
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 0,
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'],
|
||||
|
||||
// 'newlines-between': 'always',
|
||||
|
||||
alphabetize: {
|
||||
order: 'asc',
|
||||
caseInsensitive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
pnpm-lock.yaml
|
||||
|
||||
helper.json
|
||||
deploy.mjs
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# puton
|
||||
技术设计: https://twjgz6ufn9.feishu.cn/docx/QxYZdj5agoPZKWxzXaRcUZnknMX
|
||||
|
||||
UI: https://www.figma.com/design/ow4g9BhwPcmTAYJiCNVbsN/AI%E7%BE%8E%E6%90%AD%E5%8A%A9%E6%89%8B?node-id=1650-2246&t=TPutnQiEWkaaUVNi-0
|
||||
|
||||
|
||||
Element Plus 自动引入图标的实现原理是从 iconify 中自动导入所需要的图标,而 iconify 是一个图标库,包含 Element Plus、Ant Design 等图标集,在具体使用时,语法为 <i-图标集英文缩写-图标名 />,不能直接使用 element plus 中支持的 <图标名 /> 语法。
|
||||
https://icon-sets.iconify.design/?query=edit
|
||||
```html
|
||||
<i-ep-Edit />
|
||||
<i-ep-edit-pen></i-ep-edit-pen>
|
||||
<el-icon size="20" color="red">
|
||||
<i-ep-delete></i-ep-delete>
|
||||
</el-icon>
|
||||
```
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3MTkxMzA5NjcsInVzZXJuYW1lIjoiMTU3MzI4OTI4ODMifQ.2rxlU2yYD4Xqj92jOd9j0UxVrG-Aq6g6DEiE3o-7YRY
|
||||
|
||||
https://antoniandre.github.io/splitpanes/example-another-view
|
||||
|
||||
https://detail.vip.com/detail-1710617435-6920666561686120027.html
|
||||
|
||||
老网站: http://puton.aidigitalfield.com/
|
||||
|
||||
接口文档: https://i8ar7va0x1.feishu.cn/docx/R6Nod8bcPohRh2xOYbAceof4nxd
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import { App, defineComponent } from 'vue'
|
||||
const component: ReturnType<typeof defineComponent> & {
|
||||
install(app: App): void
|
||||
}
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PutOn美搭助手_AI穿搭平台_数字力场</title>
|
||||
<style>
|
||||
.el-image-viewer__actions,
|
||||
.el-image-viewer__close {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
|
||||
.custom-message-btn {
|
||||
background-color: rgba(0, 0, 0, 0.8) !important;
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
|
||||
--el-button-active-color: 'none' !important;
|
||||
}
|
||||
|
||||
.custom-cancel-message-btn {
|
||||
background-color: rgba(0, 0, 0, 0.5) !important;
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
color: #fff !important;
|
||||
|
||||
--el-button-active-color: 'none' !important;
|
||||
}
|
||||
|
||||
.custom-message-box {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<meta
|
||||
name="keywords"
|
||||
content="数字力场、Puton、美搭、美搭助手、试穿、穿搭AI工具、服装搭配AI助手、时尚AI搭配工具、智能服装推荐软件、个人形象AI管理、AI穿衣搭配建议、服装AI风格指南、个性化AI穿搭建议、智能潮流穿搭助手、虚拟试衣间AI技术、AI时尚搭配平台、智能个性化服装推荐、AI服装匹配系统、服装智能推荐引擎、时尚AI智能推荐工具、个性AI穿搭建议平台、智能化服装搭配解决方案、AI时尚潮流分析、人工智能服装个性化推荐、智能化服装搭配技术、AI虚拟试衣间体验、服装智能AI搭配服务、个性化AI时尚搭配建议、时尚AI穿搭智能助手、AI服装风格智能分析、智能化个性化服装推荐、服装AI智能化搭配系统、智能AI时尚潮流推荐、AI服装搭配智能引擎、时尚AI虚拟试穿技术"
|
||||
/>
|
||||
|
||||
<meta
|
||||
name="description"
|
||||
content="数字力场、Puton、美搭、美搭助手、试穿、穿搭AI工具、服装搭配AI助手、时尚AI搭配工具、智能服装推荐软件、个人形象AI管理、AI穿衣搭配建议、服装AI风格指南、个性化AI穿搭建议、智能潮流穿搭助手、虚拟试衣间AI技术、AI时尚搭配平台、智能个性化服装推荐、AI服装匹配系统、服装智能推荐引擎、时尚AI智能推荐工具、个性AI穿搭建议平台、智能化服装搭配解决方案、AI时尚潮流分析、人工智能服装个性化推荐、智能化服装搭配技术、AI虚拟试衣间体验、服装智能AI搭配服务、个性化AI时尚搭配建议、时尚AI穿搭智能助手、AI服装风格智能分析、智能化个性化服装推荐、服装AI智能化搭配系统、智能AI时尚潮流推荐、AI服装搭配智能引擎、时尚AI虚拟试穿技术"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "puton",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"bp": "pnpm build && node deploy.mjs",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build --force",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@types/axios": "^0.14.0",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"@vueuse/integrations": "^10.11.0",
|
||||
"axios": "^1.3.4",
|
||||
"element-plus": "^2.7.6",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"lodash": "^4.17.21",
|
||||
"node-ssh": "^13.2.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
"pinia": "^2.1.7",
|
||||
"splitpanes": "^3.1.5",
|
||||
"swiper": "^11.1.4",
|
||||
"universal-cookie": "^6",
|
||||
"unplugin-icons": "^0.19.0",
|
||||
"vue": "^3.4.29",
|
||||
"vue-awesome-swiper": "^5.0.1",
|
||||
"vue-global-api": "^0.4.1",
|
||||
"vue-i18n": "9",
|
||||
"vue-router": "^4.3.3",
|
||||
"vue-virtual-scroller": "2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/ep": "^1.1.15",
|
||||
"@rushstack/eslint-patch": "^1.8.0",
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/node": "^20.14.5",
|
||||
"@types/splitpanes": "^2.2.6",
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"@vitejs/plugin-vue-jsx": "^4.0.0",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.23.0",
|
||||
"npm-run-all2": "^6.2.0",
|
||||
"prettier": "^3.2.5",
|
||||
"sass": "^1.77.6",
|
||||
"typescript": "~5.4.0",
|
||||
"unplugin-auto-import": "^0.17.6",
|
||||
"unplugin-vue-components": "^0.27.2",
|
||||
"vite": "^5.3.1",
|
||||
"vue-tsc": "^2.0.21"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="bed">
|
||||
<HomePage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HomePage from '@/pages/HomePage.vue'
|
||||
|
||||
const globalBg = `url(https://digit-person.oss-cn-beijing.aliyuncs.com/digital_cloth/1720063985760.png)`
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bed {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
background-image: v-bind(globalBg);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
// overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1743_871)">
|
||||
<path d="M9.625 11H1.375C0.61875 11 0 10.3813 0 9.625V1.375C0 0.61875 0.61875 0 1.375 0H9.625C10.3813 0 11 0.61875 11 1.375V9.625C11 10.3813 10.3813 11 9.625 11ZM1.375 0.6875C0.9625 0.6875 0.6875 0.9625 0.6875 1.375V9.625C0.6875 10.0375 0.9625 10.3125 1.375 10.3125H9.625C10.0375 10.3125 10.3125 10.0375 10.3125 9.625V1.375C10.3125 0.9625 10.0375 0.6875 9.625 0.6875H1.375Z" fill="#2C2C2C"/>
|
||||
<path d="M8.25 5.84375H2.75C2.54375 5.84375 2.40625 5.70625 2.40625 5.5C2.40625 5.29375 2.54375 5.15625 2.75 5.15625H8.25C8.45625 5.15625 8.59375 5.29375 8.59375 5.5C8.59375 5.70625 8.45625 5.84375 8.25 5.84375Z" fill="#2C2C2C"/>
|
||||
<path d="M5.5 8.59375C5.29375 8.59375 5.15625 8.45625 5.15625 8.25V2.75C5.15625 2.54375 5.29375 2.40625 5.5 2.40625C5.70625 2.40625 5.84375 2.54375 5.84375 2.75V8.25C5.84375 8.45625 5.70625 8.59375 5.5 8.59375Z" fill="#2C2C2C"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1743_871">
|
||||
<rect width="11" height="11" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 630 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,35 @@
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
export {}
|
||||
declare global {
|
||||
const ApiEnum: typeof import('./enum/ApiEnum')['ApiEnum']
|
||||
const CacheEnum: typeof import('./enum/CacheEnum')['CacheEnum']
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const ElLoading: typeof import('element-plus/es')['ElLoading']
|
||||
const ElMessage: typeof import('element-plus/es')['ElMessage']
|
||||
const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
|
||||
const HttpCodeEnum: typeof import('./enum/HttpCodeEnum')['HttpCodeEnum']
|
||||
const MenuEnum: typeof import('./enum/MenuEnum')['MenuEnum']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
// @ts-ignore
|
||||
export type { ApiEnum } from './enum/ApiEnum'
|
||||
import('./enum/ApiEnum')
|
||||
// @ts-ignore
|
||||
export type { CacheEnum } from './enum/CacheEnum'
|
||||
import('./enum/CacheEnum')
|
||||
// @ts-ignore
|
||||
export type { HttpCodeEnum } from './enum/HttpCodeEnum'
|
||||
import('./enum/HttpCodeEnum')
|
||||
// @ts-ignore
|
||||
export type { MenuEnum } from './enum/MenuEnum'
|
||||
import('./enum/MenuEnum')
|
||||
}
|
||||
// for vue template auto import
|
||||
import { UnwrapRef } from 'vue'
|
||||
declare module 'vue' {
|
||||
interface GlobalComponents {}
|
||||
interface ComponentCustomProperties {
|
||||
readonly ApiEnum: UnwrapRef<typeof import('./enum/ApiEnum')['ApiEnum']>
|
||||
readonly CacheEnum: UnwrapRef<typeof import('./enum/CacheEnum')['CacheEnum']>
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly ElMessage: UnwrapRef<typeof import('element-plus/es')['ElMessage']>
|
||||
readonly ElMessageBox: UnwrapRef<typeof import('element-plus/es')['ElMessageBox']>
|
||||
readonly HttpCodeEnum: UnwrapRef<typeof import('./enum/HttpCodeEnum')['HttpCodeEnum']>
|
||||
readonly MenuEnum: UnwrapRef<typeof import('./enum/MenuEnum')['MenuEnum']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
|
||||
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
||||
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
||||
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
||||
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
|
||||
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
|
||||
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
|
||||
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
|
||||
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
|
||||
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
|
||||
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
|
||||
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
|
||||
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
|
||||
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
|
||||
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
|
||||
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
|
||||
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
|
||||
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
|
||||
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
|
||||
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
|
||||
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
|
||||
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
||||
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
||||
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
|
||||
readonly ref: UnwrapRef<typeof import('vue')['ref']>
|
||||
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
||||
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
|
||||
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
|
||||
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
|
||||
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
|
||||
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
||||
readonly unref: UnwrapRef<typeof import('vue')['unref']>
|
||||
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
||||
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
||||
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
|
||||
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
|
||||
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
|
||||
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
|
||||
readonly watch: UnwrapRef<typeof import('vue')['watch']>
|
||||
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
|
||||
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
|
||||
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
<template>
|
||||
<ScrollWithBar>
|
||||
<div class="clothes-box" ref="containerRef" v-if="props.allMyClothes">
|
||||
<el-row>
|
||||
<el-col
|
||||
:xs="12"
|
||||
:sm="6"
|
||||
:md="4"
|
||||
:lg="3"
|
||||
:xl="2"
|
||||
v-for="item in _clothes"
|
||||
:key="item.clothId"
|
||||
>
|
||||
<div class="item">
|
||||
<template v-if="item.clothType === 'suit'">
|
||||
<div class="suit">
|
||||
<el-image
|
||||
class="cover-img"
|
||||
:src="item.suitList[0].thumbnailFileUrl"
|
||||
fit="cover"
|
||||
lazy
|
||||
:scroll-container="containerRef"
|
||||
/>
|
||||
|
||||
<div class="suit-bottomCloth">
|
||||
<el-image
|
||||
:src="item.suitList[1].thumbnailFileUrl"
|
||||
fit="fill"
|
||||
lazy
|
||||
:scroll-container="containerRef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<el-image
|
||||
class="cover-img"
|
||||
:src="item.thumbnailFileUrl"
|
||||
fit="cover"
|
||||
lazy
|
||||
:scroll-container="containerRef"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="item.fileStatus === 'enable'">
|
||||
<div class="mutiple">
|
||||
<MyIcons
|
||||
:type="
|
||||
props.roomClothes?.some((i) => i.clothId === item.clothId)
|
||||
? 'selected'
|
||||
: 'noSelected'
|
||||
"
|
||||
cursor="pointer"
|
||||
:size="16"
|
||||
class="checkbox"
|
||||
@click="mutipleRoomSelectClothing(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="disable-mask">
|
||||
<span> {{ $t('tips.training') }} </span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="clothes-box" ref="containerRef" v-if="props.clothes">
|
||||
<el-row>
|
||||
<el-col
|
||||
:xs="12"
|
||||
:sm="6"
|
||||
:md="4"
|
||||
:lg="3"
|
||||
:xl="2"
|
||||
v-for="(item, index) in _clothes"
|
||||
:key="index"
|
||||
>
|
||||
<div class="item">
|
||||
<template v-if="item.clothType === 'suit'">
|
||||
<div class="suit">
|
||||
<el-image
|
||||
class="cover-img"
|
||||
:src="item.suitList[0].thumbnailFileUrl"
|
||||
fit="cover"
|
||||
lazy
|
||||
:scroll-container="containerRef"
|
||||
/>
|
||||
|
||||
<div class="suit-bottomCloth">
|
||||
<el-image
|
||||
:src="item.suitList[1].thumbnailFileUrl"
|
||||
fit="fill"
|
||||
lazy
|
||||
:scroll-container="containerRef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<el-image
|
||||
class="cover-img"
|
||||
:src="item.thumbnailFileUrl"
|
||||
fit="cover"
|
||||
lazy
|
||||
:scroll-container="containerRef"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="item.fileStatus === 'enable'">
|
||||
<template v-if="props.mutipleMode">
|
||||
<div class="mutiple">
|
||||
<MyIcons
|
||||
:type="
|
||||
(
|
||||
item.clothType === 'suit'
|
||||
? selectMutipleClothesIds?.some(
|
||||
(i) =>
|
||||
i.toString() ==
|
||||
JSON.parse(item.thumbnailFileUrl)
|
||||
.map((i: any) => i.clothId)
|
||||
.toString()
|
||||
)
|
||||
: selectMutipleClothesIds?.includes(item.clothId)
|
||||
)
|
||||
? 'selected'
|
||||
: 'noSelected'
|
||||
"
|
||||
cursor="pointer"
|
||||
:size="16"
|
||||
class="checkbox"
|
||||
@click="mutipleSelectClothing(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="tool-mask" v-else>
|
||||
<div class="tool-header">
|
||||
<template v-if="props.mine">
|
||||
<span @click="onDelete(item)">{{ $t('tips.delete') }}</span>
|
||||
<span @click="onLink(item)">{{ $t('tips.buyLink') }}</span>
|
||||
<span @click="onUploadMultipleImg(item)">{{ $t('tips.more') }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span v-if="item.isHas === 'no'" @click="addToMyClothes(item)">{{
|
||||
$t('tips.addToCloset')
|
||||
}}</span>
|
||||
<span v-else @click="unJoinFromMyClothes(item)">{{ $t('cancel') }}</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="tool-footer">
|
||||
<span @click="selectClothing(item)">{{ $t('tips.select') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="disable-mask">
|
||||
<span> {{ $t('tips.training') }} </span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="clothes-box" v-if="props.urls">
|
||||
<el-row>
|
||||
<el-col
|
||||
:xs="4"
|
||||
:sm="4"
|
||||
:md="4"
|
||||
:lg="4"
|
||||
:xl="4"
|
||||
v-for="(item, index) in urlsRaw"
|
||||
:key="index"
|
||||
>
|
||||
<div
|
||||
class="item history"
|
||||
:class="{
|
||||
noTop: index > 5,
|
||||
haveLeft: (index + 1) % 6 == 1
|
||||
}"
|
||||
>
|
||||
<el-image
|
||||
class="cover-img"
|
||||
:src="item.url"
|
||||
fit="cover"
|
||||
:preview-src-list="
|
||||
creatingAndFailMarkList.some((mark) => item.url.includes(mark))
|
||||
? []
|
||||
: props.urls
|
||||
.filter((i) => !creatingAndFailMarkList.some((mark) => i.url.includes(mark)))
|
||||
.map((i) => i.url)
|
||||
"
|
||||
preview-teleported
|
||||
:initial-index="index"
|
||||
:infinite="false"
|
||||
/>
|
||||
|
||||
<div class="tool-mask">
|
||||
<div class="delete">
|
||||
<span @click="onDeleteHistory(item)">{{ $t('tips.delete') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="preview"
|
||||
v-if="!creatingAndFailMarkList.some((mark) => item.url.includes(mark))"
|
||||
>
|
||||
<div>{{ $t('tips.preview') }}</div>
|
||||
|
||||
<el-image
|
||||
class="previewSit"
|
||||
:src="item.url"
|
||||
:preview-src-list="
|
||||
props.urls
|
||||
.map((i) => i.url)
|
||||
.filter((i) => !creatingAndFailMarkList.some((mark) => i.includes(mark)))
|
||||
"
|
||||
preview-teleported
|
||||
:initial-index="index"
|
||||
:infinite="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<template v-if="(props.clothes && !props.clothes.length) || (props.urls && !props.urls.length)">
|
||||
<el-empty description="暂无数据" />
|
||||
</template>
|
||||
</ScrollWithBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ScrollWithBar from '@/components/ScrollWithBar.vue'
|
||||
import { type IClotheList, type IClothType, type IClotheInfo } from '@/interface'
|
||||
import { useGlobalStore } from '@/stores/globalStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
const { creatingAndFailMarkList, selectMutipleClothesIds } = storeToRefs(globalStore)
|
||||
const { updateSelectMutileClothesIds } = globalStore
|
||||
|
||||
const containerRef = ref()
|
||||
|
||||
type IProps = {
|
||||
clothes?: IClotheList
|
||||
allMyClothes?: IClotheList
|
||||
roomClothes?: IClotheList
|
||||
urls?: { historyId: string; url: string }[]
|
||||
paddingRight?: number
|
||||
mine?: boolean
|
||||
mutipleMode?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<IProps>()
|
||||
|
||||
const urlsRaw = toRaw(props.urls)
|
||||
|
||||
const _clothes = computed(() =>
|
||||
(props.clothes || props.allMyClothes)?.map((i) => {
|
||||
let suitList = []
|
||||
if (i.clothType === 'suit') {
|
||||
suitList = JSON.parse(i.thumbnailFileUrl)
|
||||
}
|
||||
|
||||
return { ...i, suitList }
|
||||
})
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
addToMyClothes: [{ clothType: IClothType; clothId: string }]
|
||||
unJoinFromMyClothes: [{ clothId: string }]
|
||||
selectClothing: [data: IClotheInfo]
|
||||
deleteClothing: [data: IClotheInfo]
|
||||
onDeleteHistory: [data: any]
|
||||
onUploadMultipleImg: [data: IClotheInfo]
|
||||
onUploadLink: [data: { clothId: string; buyLink: string }]
|
||||
mutipleSelectClothing: [data: IClotheInfo[]]
|
||||
addOrRmRoomCloth: [data: IClotheInfo]
|
||||
}>()
|
||||
|
||||
const addToMyClothes = (data: IClotheInfo) => emit('addToMyClothes', data)
|
||||
|
||||
const unJoinFromMyClothes = (data: IClotheInfo) => emit('unJoinFromMyClothes', data)
|
||||
|
||||
const selectClothing = (data: IClotheInfo) => emit('selectClothing', data)
|
||||
|
||||
const onDelete = (item: IClotheInfo) => emit('deleteClothing', item)
|
||||
|
||||
const onDeleteHistory = (item: { historyId: string; url: string }) => emit('onDeleteHistory', item)
|
||||
|
||||
const onLink = (item: IClotheInfo) => {
|
||||
// console.log('AT-[ onLink &&&&&********** ]', item)
|
||||
|
||||
// alert('缺少添加购买链接接口和相关的字段')
|
||||
if (!item.buyLink) {
|
||||
ElMessageBox.prompt(t('tips.inputBuyLink'), {
|
||||
confirmButtonText: t('tips.confirmText'),
|
||||
cancelButtonText: t('tips.cancelText'),
|
||||
confirmButtonClass: 'custom-message-btn',
|
||||
cancelButtonClass: 'custom-cancel-message-btn',
|
||||
customClass: 'custom-message-box',
|
||||
inputValidator(val) {
|
||||
try {
|
||||
new URL(val)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
inputErrorMessage: t('tips.inputErrorMessage')
|
||||
}).then(async ({ value }) => {
|
||||
emit('onUploadLink', { clothId: item.clothId, buyLink: value })
|
||||
})
|
||||
} else {
|
||||
window.location.href = item.buyLink
|
||||
}
|
||||
}
|
||||
|
||||
const onUploadMultipleImg = (item: IClotheInfo) => emit('onUploadMultipleImg', item)
|
||||
|
||||
const mutipleSelectClothing = (item: IClotheInfo) => {
|
||||
updateSelectMutileClothesIds(item.clothId, item)
|
||||
// updateSelectedCloth(item)
|
||||
}
|
||||
|
||||
const mutipleRoomSelectClothing = (item: any) => {
|
||||
// console.log('AT-[ item &&&&&********** ]', item)
|
||||
emit('addOrRmRoomCloth', item)
|
||||
}
|
||||
|
||||
const paddingRight = `${props.paddingRight === undefined ? 10 : props.paddingRight}px`
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$border: 0.5px solid rgba(0, 0, 0, 1);
|
||||
|
||||
.clothes-box {
|
||||
padding-right: v-bind(paddingRight);
|
||||
|
||||
.item {
|
||||
border: $border;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-top: 150%;
|
||||
overflow: hidden;
|
||||
|
||||
.cover-img {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: block;
|
||||
> img {
|
||||
display: block;
|
||||
font-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.suit-bottomCloth {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
right: 0;
|
||||
width: 50%;
|
||||
transform: translateY(-100%);
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.tool-mask {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-mask {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
// display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
|
||||
> div {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
> span {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
height: 65%;
|
||||
flex-direction: column;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.delete {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.preview {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
.previewSit {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background-color: pink;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-footer {
|
||||
flex: 1;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.mutiple {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
|
||||
.checkbox {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 10px;
|
||||
z-index: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.disable-mask {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.history {
|
||||
// border: $border;
|
||||
box-sizing: border-box;
|
||||
border: $border;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.noTop {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.haveLeft {
|
||||
border-left: $border;
|
||||
}
|
||||
.havetop {
|
||||
border-top: $border;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="clothes-pool">
|
||||
<div class="tip">
|
||||
{{ $t('tips.select') }} <span>{{ selectedRoomInfo?.roomName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="pool-box">
|
||||
<ClothesBox
|
||||
@addOrRmRoomCloth="props.addOrRmRoomCloth"
|
||||
:roomClothes="props.roomClothes"
|
||||
:allMyClothes="props.allMyClothes"
|
||||
mutipleMode
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { IClotheList, IClotheInfo } from '@/interface'
|
||||
// import api from '@/plugins/axios'
|
||||
import { useGlobalStore } from '@/stores/globalStore'
|
||||
|
||||
// const emit = defineEmits<{
|
||||
// addOrRmRoomCloth: [data: IClotheInfo]
|
||||
// }>()
|
||||
|
||||
const props = defineProps<{
|
||||
allMyClothes: IClotheList
|
||||
roomClothes: IClotheList
|
||||
addOrRmRoomCloth: (item: IClotheInfo) => Promise<void>
|
||||
}>()
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
const { selectedRoomInfo } = storeToRefs(globalStore)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.clothes-pool {
|
||||
padding-left: 10px;
|
||||
// padding-right: 20px;
|
||||
border-right: 0.5px solid #000;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: #000;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -5px;
|
||||
width: 9px;
|
||||
height: 1px;
|
||||
background-color: #000;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.pool-box {
|
||||
height: calc(100% - 38px);
|
||||
overflow: hidden;
|
||||
border-top: 0.5px solid #000;
|
||||
border-left: 0.5px solid #000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { IRoomInfo } from '@/interface'
|
||||
import IconAdd from '@/components/icons/IconAdd.vue'
|
||||
import IconDelete from '@/components/icons/IconDelete.vue'
|
||||
import IconEdit from '@/components/icons/IconEdit.vue'
|
||||
import IconLink from '@/components/icons/IconLink.vue'
|
||||
import api from '@/plugins/axios'
|
||||
import { useGlobalStore } from '@/stores/globalStore'
|
||||
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
const { updateRoomInfo } = globalStore
|
||||
|
||||
const { selectedRoomInfo, gender } = storeToRefs(globalStore)
|
||||
|
||||
const props = defineProps<{
|
||||
roomList: IRoomInfo[]
|
||||
freshRoomList: () => Promise<void>
|
||||
getRoomListLoading: boolean
|
||||
}>()
|
||||
|
||||
const input = ref('')
|
||||
|
||||
const inputRoomRef = ref()
|
||||
|
||||
const tempRoomName = ref('')
|
||||
|
||||
const status = ref(false)
|
||||
|
||||
// const activeRoomItem = ref<IRoomItem>()
|
||||
|
||||
type IRoomItem = Omit<IRoomInfo, 'createTime'> & { selected: boolean; edit?: boolean }
|
||||
|
||||
type IState = IRoomItem[]
|
||||
|
||||
const state = ref<IState>([])
|
||||
|
||||
watchEffect(() => {
|
||||
state.value = props.roomList.map((i) => Object.assign({ ...i, selected: false }))
|
||||
})
|
||||
|
||||
const handleEdit = (item: IRoomItem) => {
|
||||
state.value.map((val) => {
|
||||
val.edit = false
|
||||
if (val.roomId === item.roomId) {
|
||||
val.edit = true
|
||||
}
|
||||
return val
|
||||
})
|
||||
}
|
||||
|
||||
const editInput = (value: string, index: number) => {
|
||||
state.value[index].roomName = value
|
||||
}
|
||||
|
||||
const handleSelect = (item: IRoomItem) => {
|
||||
// activeRoomItem.value = item
|
||||
|
||||
updateRoomInfo(item)
|
||||
}
|
||||
|
||||
const updateRoom = async (item: IRoomItem) => {
|
||||
if (status.value) {
|
||||
return
|
||||
}
|
||||
|
||||
status.value = true
|
||||
|
||||
if (item.roomName) {
|
||||
await api.updateRoom({ roomId: item.roomId, roomName: item.roomName })
|
||||
}
|
||||
|
||||
await props.freshRoomList()
|
||||
|
||||
status.value = false
|
||||
}
|
||||
|
||||
const handleDelete = async (item: IRoomItem) => {
|
||||
await api.deleteRoom({ roomId: item.roomId })
|
||||
|
||||
props.freshRoomList()
|
||||
}
|
||||
|
||||
const add = async () => {
|
||||
if (!input.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await api.addRoom({ roomName: input.value, sex: gender.value })
|
||||
inputRoomRef.value.clear()
|
||||
props.freshRoomList()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<div class="dropdown-menu" v-loading="props.getRoomListLoading">
|
||||
<div class="dropdown-item" v-for="(item, i) in state" :key="i">
|
||||
<div class="dropdown-item-name">
|
||||
<div class="botBox">
|
||||
<span
|
||||
:class="{
|
||||
bot: item.roomId != selectedRoomInfo?.roomId,
|
||||
activeBot: item.roomId === selectedRoomInfo?.roomId
|
||||
}"
|
||||
></span>
|
||||
</div>
|
||||
<span
|
||||
:class="{ text: true, activeText: item.roomId === selectedRoomInfo?.roomId }"
|
||||
@click="handleSelect(item)"
|
||||
>
|
||||
<template v-if="!item.edit">{{ item.roomName }}</template>
|
||||
|
||||
<template v-else>
|
||||
<el-input
|
||||
:model-value="(tempRoomName = item.roomName)"
|
||||
@input="(val: string) => editInput(val, i)"
|
||||
autofocus
|
||||
style="width: 140px"
|
||||
size="small"
|
||||
class="edit-input"
|
||||
maxlength="9"
|
||||
@blur="() => updateRoom(item)"
|
||||
@keyup.enter="() => updateRoom(item)"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="dropdown-item-operating">
|
||||
<el-icon class="dropdown-item-icon" size="16">
|
||||
<IconLink />
|
||||
</el-icon>
|
||||
<el-icon @click="handleEdit(item)" class="dropdown-item-icon" size="13">
|
||||
<IconEdit />
|
||||
</el-icon>
|
||||
<el-icon @click="handleDelete(item)" class="dropdown-item-icon" size="14">
|
||||
<IconDelete />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="add_fittingRoom">
|
||||
<el-input
|
||||
v-model="input"
|
||||
ref="inputRoomRef"
|
||||
style="width: 240px; border: 1px solid #000000"
|
||||
placeholder="添加专题试衣间"
|
||||
@blur="add"
|
||||
@keyup.enter="add"
|
||||
maxlength="9"
|
||||
>
|
||||
<template #suffix>
|
||||
<div class="add_icon" @click="add"><IconAdd /></div>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit-input {
|
||||
& :deep(.el-input__wrapper) {
|
||||
// --el-button-active-color: 'none' !important;
|
||||
// border-radius: 0 !important;
|
||||
// box-shadow: none !important;
|
||||
box-shadow: 0 0 0 1px #000 inset !important;
|
||||
box-shadow: #000 !important;
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
.dropdown-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.add_fittingRoom {
|
||||
& :deep(.el-input__wrapper) {
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
// margin-top: 120px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.dropdown-item-name {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.botBox {
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.bot {
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.text {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.activeText {
|
||||
color: #776ce6;
|
||||
}
|
||||
|
||||
.activeBot {
|
||||
display: inline-block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #776ce6;
|
||||
}
|
||||
}
|
||||
|
||||
.add_icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-item-operating {
|
||||
flex: 0 0 60px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.dropdown-item-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="baby-select">
|
||||
<div class="select-box">
|
||||
<div class="baby-select-header" @click.stop="handleClickHeader">
|
||||
{{ items.selected }}
|
||||
<img class="arrow" :class="{ rotate: isShowSelect }" :src="arrowDownIcon" alt="" />
|
||||
</div>
|
||||
|
||||
<div class="hide-select" v-if="isShowSelect" ref="target" @click.stop="handelSelect">
|
||||
{{ items.option }} <img class="arrow" :src="arrowDownIcon" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="select-main" type="input" @click="() => open()">
|
||||
<MyIcons type="preson" :size="50" class="person" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFileDialog, onClickOutside } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import arrowDownIcon from '@/assets/imgs/arrow_down.png'
|
||||
import MyIcons from '@/components/MyIcons.vue'
|
||||
|
||||
import { useGlobalStore } from '@/stores/globalStore'
|
||||
|
||||
const globalStore = useGlobalStore()
|
||||
const { updateGender } = globalStore
|
||||
const { gender } = storeToRefs(globalStore)
|
||||
|
||||
enum IEnumMaps {
|
||||
BOY = 'male',
|
||||
GIRL = 'female'
|
||||
}
|
||||
|
||||
type IEmitTypes = {
|
||||
onChange: [gender: 'male' | 'female']
|
||||
onFileChange: [formData: FormData]
|
||||
}
|
||||
|
||||
const emit = defineEmits<IEmitTypes>()
|
||||
|
||||
const target = ref(null)
|
||||
|
||||
const isShowSelect = ref(false)
|
||||
|
||||
type IItems = { selected: 'GIRL'; option: 'BOY' } | { selected: 'BOY'; option: 'GIRL' }
|
||||
|
||||
const items = ref<IItems>(
|
||||
gender.value === 'female'
|
||||
? { selected: 'GIRL', option: 'BOY' }
|
||||
: { selected: 'BOY', option: 'GIRL' }
|
||||
)
|
||||
|
||||
const {
|
||||
files,
|
||||
open,
|
||||
onChange: onFileChange
|
||||
} = useFileDialog({ accept: 'image/*', multiple: false })
|
||||
|
||||
onFileChange(() => {
|
||||
console.log('AT-[ files &&&&&********** ]', files.value)
|
||||
|
||||
if (files?.value) {
|
||||
const file = files?.value[0]
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
emit('onFileChange', formData)
|
||||
}
|
||||
})
|
||||
|
||||
onClickOutside(target, () => {
|
||||
isShowSelect.value = false
|
||||
})
|
||||
|
||||
const handelSelect = () => {
|
||||
isShowSelect.value = false
|
||||
|
||||
items.value =
|
||||
items.value.selected === 'BOY'
|
||||
? { selected: 'GIRL', option: 'BOY' }
|
||||
: { selected: 'BOY', option: 'GIRL' }
|
||||
|
||||
updateGender(IEnumMaps[items.value.selected])
|
||||
|
||||
emit('onChange', IEnumMaps[items.value.selected])
|
||||
}
|
||||
|
||||
const handleClickHeader = () => {
|
||||
isShowSelect.value = !isShowSelect.value
|
||||
}
|
||||
|
||||
emit('onChange', IEnumMaps[items.value.selected])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$border1: 0.5px solid #000;
|
||||
|
||||
.bordeer1 {
|
||||
border: $border1;
|
||||
}
|
||||
|
||||
.baby-select {
|
||||
width: 78px;
|
||||
// height: 102px;
|
||||
// width: 100%;
|
||||
height: calc(100% - 1px);
|
||||
background-color: #fff;
|
||||
position: relative;
|
||||
border: $border1;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
margin: 0 auto;
|
||||
|
||||
.select-box {
|
||||
.baby-select-header {
|
||||
height: 27px;
|
||||
width: 100%;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
.arrow {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
transform: rotate(-180deg);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.rotate {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.hide-select {
|
||||
@extend .baby-select-header;
|
||||
|
||||
transition: all 0.3s;
|
||||
background-color: #7b68ee;
|
||||
border: $border1;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
display: flex;
|
||||
|
||||
& .arrow {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select-main {
|
||||
height: calc(100% - 27px);
|
||||
}
|
||||
|
||||
.person {
|
||||
width: 50px;
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div class="clothes-box" v-if="isShow">
|
||||
<el-row>
|
||||
<el-col :xs="4" :sm="4" :md="4" :lg="4" :xl="4" v-for="(item, index) in urlsRaw" :key="index">
|
||||
<div
|
||||
class="item history"
|
||||
:class="{
|
||||
noTop: index > 5,
|
||||
haveLeft: (index + 1) % 6 == 1
|
||||
}"
|
||||
style="overflow: auto"
|
||||
>
|
||||
<el-image class="cover-img" v-lazy="item.url" :src="item.url" :lazy="true" />
|
||||
|
||||
<div class="tool-mask" v-if="false">
|
||||
<div class="delete">
|
||||
<span @click="onDeleteHistory(item)">{{ $t('tips.delete') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="preview"
|
||||
v-if="!creatingAndFailMarkList.some((mark) => item.url.includes(mark))"
|
||||
>
|
||||
<div>{{ $t('tips.preview') }}</div>
|
||||
|
||||
<el-image
|
||||
class="previewSit"
|
||||
v-lazy="item.url"
|
||||
:lazy="true"
|
||||
:src="item.url"
|
||||
:preview-src-list="
|
||||
(props.urls || [])
|
||||
.map((i) => i.url)
|
||||
.filter((i) => !creatingAndFailMarkList.some((mark) => i.includes(mark)))
|
||||
"
|
||||
preview-teleported
|
||||
:initial-index="index"
|
||||
:infinite="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
// import { emit } from 'vite'
|
||||
import { type IClotheList } from '@/interface'
|
||||
import { useGlobalStore } from '@/stores/globalStore'
|
||||
|
||||
const isShow = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
console.log('AT-[ onMounted &&&&&********** ]')
|
||||
|
||||
setTimeout(() => {
|
||||
isShow.value = true
|
||||
}, 500)
|
||||
})
|
||||
|
||||
// const { t } = useI18n()
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
type IProps = {
|
||||
clothes?: IClotheList
|
||||
allMyClothes?: IClotheList
|
||||
roomClothes?: IClotheList
|
||||
urls?: { historyId: string; url: string }[]
|
||||
paddingRight?: number
|
||||
mine?: boolean
|
||||
mutipleMode?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<IProps>()
|
||||
|
||||
const urlsRaw = toRaw(props.urls)
|
||||
|
||||
const emit = defineEmits<{
|
||||
onDeleteHistory: [data: { historyId: string; url: string }]
|
||||
}>()
|
||||
|
||||
const onDeleteHistory = (item: { historyId: string; url: string }) => emit('onDeleteHistory', item)
|
||||
|
||||
const { creatingAndFailMarkList, selectMutipleClothesIds } = storeToRefs(globalStore)
|
||||
const { updateSelectMutileClothesIds } = globalStore
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$border: 0.5px solid rgba(0, 0, 0, 1);
|
||||
|
||||
.clothes-box {
|
||||
padding-right: v-bind(paddingRight);
|
||||
|
||||
.item {
|
||||
border: $border;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-top: 150%;
|
||||
overflow: hidden;
|
||||
|
||||
.cover-img {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: block;
|
||||
> img {
|
||||
display: block;
|
||||
font-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.suit-bottomCloth {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
right: 0;
|
||||
width: 50%;
|
||||
transform: translateY(-100%);
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.tool-mask {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-mask {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
// display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
|
||||
> div {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
> span {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
height: 65%;
|
||||
flex-direction: column;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.delete {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.preview {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
.previewSit {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background-color: pink;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-footer {
|
||||
flex: 1;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.mutiple {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
|
||||
.checkbox {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 10px;
|
||||
z-index: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.disable-mask {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.history {
|
||||
// border: $border;
|
||||
box-sizing: border-box;
|
||||
border: $border;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.noTop {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.haveLeft {
|
||||
border-left: $border;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="history-scroll" v-infinite-scroll="load" :infinite-scroll-distance="50">
|
||||
<template v-if="data?.length">
|
||||
<el-checkbox-group @change="checkboxChange" :model-value="props.checked">
|
||||
<div v-for="(it, idx) in data" :key="idx" class="block-date">
|
||||
<div class="history-tip">
|
||||
<div class="createTime">{{ it.createTime }}</div>
|
||||
|
||||
<div class="checkbox-btn">
|
||||
<el-checkbox :value="idx" :label="$t('tips.selectAll')" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row>
|
||||
<el-col
|
||||
:xs="4"
|
||||
:sm="4"
|
||||
:md="4"
|
||||
:lg="4"
|
||||
:xl="4"
|
||||
v-for="(item, index) in it.clothList"
|
||||
:key="item.historyId"
|
||||
>
|
||||
<div
|
||||
class="item history"
|
||||
:class="{
|
||||
noTop: index > 5,
|
||||
haveLeft: (index + 1) % 6 == 1,
|
||||
havetop: index + 1 <= 6
|
||||
}"
|
||||
>
|
||||
<el-image class="cover-img" :src="item.url" fit="cover" :lazy="true" />
|
||||
|
||||
<div class="tool-mask">
|
||||
<div class="delete">
|
||||
<span @click="onDeleteHistory(item)">{{ $t('tips.delete') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="preview"
|
||||
v-if="!creatingAndFailMarkList.some((mark) => item.url.includes(mark))"
|
||||
>
|
||||
<div>{{ $t('tips.preview') }}</div>
|
||||
|
||||
<el-image
|
||||
class="previewSit"
|
||||
:src="item.url"
|
||||
:preview-src-list="
|
||||
it.clothList
|
||||
.map((i) => i.url)
|
||||
.filter((i) => !creatingAndFailMarkList.some((mark) => i.includes(mark)))
|
||||
"
|
||||
preview-teleported
|
||||
:initial-index="
|
||||
it.clothList
|
||||
.map((i) => i.url)
|
||||
.filter((i) => !creatingAndFailMarkList.some((mark) => i.includes(mark)))
|
||||
.findIndex((i) => i == item.url)
|
||||
"
|
||||
:infinite="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useGlobalStore } from '@/stores/globalStore'
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
type IProps = {
|
||||
historyList?: { createTime: string; clothList: { historyId: string; url: string }[] }[]
|
||||
checked: number[]
|
||||
paddingRight?: number
|
||||
}
|
||||
|
||||
const props = defineProps<IProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
onDeleteHistory: [data: any]
|
||||
onHistoryCheckListCHange: [value: number[]]
|
||||
}>()
|
||||
|
||||
const historyCheckListModel = defineModel()
|
||||
|
||||
const checkboxChange = (value: any) => {
|
||||
emit('onHistoryCheckListCHange', value)
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
console.log(
|
||||
'AT-[ historyCheckListModel xxxxxxxxxxxxxxxx ------------------------------ ]',
|
||||
historyCheckListModel.value
|
||||
)
|
||||
})
|
||||
|
||||
const data = ref<IProps['historyList']>([])
|
||||
|
||||
const count = ref(0)
|
||||
|
||||
const { creatingAndFailMarkList } = storeToRefs(globalStore)
|
||||
|
||||
watchEffect(() => {
|
||||
data.value = props.historyList?.slice(0, count.value)
|
||||
})
|
||||
|
||||
const onDeleteHistory = (item: { historyId: string; url: string }) => emit('onDeleteHistory', item)
|
||||
|
||||
const load = () => {
|
||||
console.log('AT-[ props.historyList?.length &&&&&********** ]', props.historyList?.length)
|
||||
console.log('AT-[ count.value &&&&&********** ]', count.value)
|
||||
|
||||
if (props.historyList) {
|
||||
if (count.value >= props.historyList.length) {
|
||||
return
|
||||
}
|
||||
|
||||
count.value = count.value + 5
|
||||
data.value = props.historyList?.slice(0, count.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$border: 0.5px solid rgba(0, 0, 0, 1);
|
||||
|
||||
.history-scroll {
|
||||
// background-color: red;
|
||||
overflow-y: scroll;
|
||||
// height: 60%;
|
||||
height: 100%;
|
||||
scrollbar-width: 0;
|
||||
|
||||
.block-date {
|
||||
margin: 0 10px;
|
||||
|
||||
.history-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: #7a7a7a;
|
||||
|
||||
& :deep(.el-checkbox) {
|
||||
color: #87909e;
|
||||
}
|
||||
|
||||
& :deep(.is-checked) {
|
||||
.el-checkbox__inner {
|
||||
background-color: #87909e;
|
||||
}
|
||||
}
|
||||
|
||||
& :deep(.el-checkbox__label) {
|
||||
color: #87909e;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
& :deep(.el-checkbox__inner) {
|
||||
border-color: #87909e;
|
||||
background-color: #87909e;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
border: $border;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-top: 150%;
|
||||
overflow: hidden;
|
||||
|
||||
.cover-img {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: block;
|
||||
> img {
|
||||
display: block;
|
||||
font-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.suit-bottomCloth {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
right: 0;
|
||||
width: 50%;
|
||||
transform: translateY(-100%);
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.tool-mask {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-mask {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
// display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
|
||||
> div {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
> span {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
height: 65%;
|
||||
flex-direction: column;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.delete {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.preview {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
.previewSit {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background-color: pink;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-footer {
|
||||
flex: 1;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.mutiple {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
|
||||
.checkbox {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 10px;
|
||||
z-index: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.disable-mask {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.history {
|
||||
// border: $border;
|
||||
box-sizing: border-box;
|
||||
border: $border;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.noTop {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.haveLeft {
|
||||
border-left: $border;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { INavStatus, IMainNav, ISubNav, IRoomInfo } from '@/interface'
|
||||
import { MENU_CONFIG } from '@/constants'
|
||||
import { MenuEnum } from '@/enum/MenuEnum'
|
||||
import { useGlobalStore } from '@/stores/globalStore'
|
||||
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
const { menu } = storeToRefs(globalStore)
|
||||
|
||||
const visible = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
alertHandle: () => void
|
||||
roomList: IRoomInfo[]
|
||||
freshRoomList: () => void
|
||||
getRoomListLoading: boolean
|
||||
roomMode: boolean
|
||||
}>()
|
||||
|
||||
type IEmitTypes = {
|
||||
onMenuChange: [navStatus: INavStatus]
|
||||
}
|
||||
|
||||
// const navStatus = reactive<INavStatus>({
|
||||
// mainNav: MenuEnum.CLOTHES_LIBRARY,
|
||||
// subNav: MenuEnum.ALL
|
||||
// })
|
||||
const navStatus = reactive<INavStatus>({ ...menu.value })
|
||||
|
||||
watchEffect(() => {
|
||||
navStatus.mainNav = menu.value.mainNav
|
||||
navStatus.subNav = menu.value.subNav
|
||||
})
|
||||
|
||||
const emit = defineEmits<IEmitTypes>()
|
||||
|
||||
const selectMainNav = (mainNav: IMainNav) => {
|
||||
if (navStatus.mainNav != mainNav) {
|
||||
navStatus.mainNav = mainNav
|
||||
emit('onMenuChange', navStatus)
|
||||
}
|
||||
}
|
||||
|
||||
const selectSubNav = (value: any) => {
|
||||
// console.log('AT-[ selectSubNav &&&&&********** ]', value)
|
||||
// const allSubNavVals: ISubNav[] = [
|
||||
// MenuEnum.ALL,
|
||||
// MenuEnum.UPPER_OUTER_GARMENT,
|
||||
// MenuEnum.BOTTOMS,
|
||||
// MenuEnum.DRESS,
|
||||
// MenuEnum.SUIT
|
||||
// ]
|
||||
const allSubNavVals = MENU_CONFIG.value.filter((i) => i.status).map((i) => i.value)
|
||||
|
||||
if (allSubNavVals.includes(value) && navStatus.subNav != value) {
|
||||
navStatus.subNav = value
|
||||
emit('onMenuChange', navStatus)
|
||||
}
|
||||
}
|
||||
|
||||
const visibleChange = (val: any) => {
|
||||
visible.value = val
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menu">
|
||||
<div
|
||||
:class="{
|
||||
item: true,
|
||||
selected: props.roomMode ? false : navStatus.mainNav === MenuEnum.CLOTHES_LIBRARY
|
||||
}"
|
||||
@click="selectMainNav(MenuEnum.CLOTHES_LIBRARY)"
|
||||
>
|
||||
<!-- 衣服库 -->
|
||||
{{ $t('nav.clothLib') }}
|
||||
</div>
|
||||
|
||||
<div class="item" @click="selectMainNav(MenuEnum.MY_WARDROBE)">
|
||||
<el-dropdown placement="bottom-start" trigger="hover" @visible-change="visibleChange">
|
||||
<span
|
||||
:class="{ selected: props.roomMode ? false : navStatus.mainNav === MenuEnum.MY_WARDROBE }"
|
||||
>
|
||||
<!-- 我的衣橱 -->
|
||||
{{ $t('nav.myCloset') }}
|
||||
</span>
|
||||
<template v-slot:dropdown>
|
||||
<div v-if="visible" class="dropdownContainer">
|
||||
<DropdownMenu
|
||||
:roomList="props.roomList"
|
||||
:freshRoomList="props.freshRoomList"
|
||||
:getRoomListLoading="props.getRoomListLoading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
/
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in MENU_CONFIG"
|
||||
:key="item.name"
|
||||
:class="{
|
||||
item: true,
|
||||
disabledContent: !item.status,
|
||||
selected: navStatus.subNav === item.value
|
||||
}"
|
||||
@click="item.status ? selectSubNav(item.value) : props.alertHandle()"
|
||||
>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu {
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
padding-bottom: 10px;
|
||||
margin-left: 16px;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
|
||||
.item {
|
||||
padding: 10px 4px 0;
|
||||
cursor: pointer;
|
||||
line-height: 24px;
|
||||
height: 24px;
|
||||
user-select: none;
|
||||
|
||||
span {
|
||||
line-height: 24px;
|
||||
height: 24px;
|
||||
display: inline-block;
|
||||
color: #000000;
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
//background: red;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
height: 1.5px;
|
||||
background: #000;
|
||||
width: 10px;
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
.disabledContent {
|
||||
color: #88909d;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.dropdownContainer {
|
||||
width: 240px;
|
||||
border: 1px solid #000000;
|
||||
padding: 20px 25px 20px 30px;
|
||||
}
|
||||
.el-popper__arrow {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="model-item-card" :class="{ active: props.active }" ref="containerRef">
|
||||
<el-image :src="thumbnailFileUrl" fit="cover" style="width: 78px; height: 117px" />
|
||||
|
||||
<div class="tool-bar" v-show="!props.mutipleMode">
|
||||
<div class="header">
|
||||
<span @click="onDelete">{{ $t('tips.delete') }}</span>
|
||||
<span @click="onModify">{{ $t('tips.modifyFace') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span @click="selectModel">{{ $t('tips.select') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mutiple" v-if="props.mutipleMode">
|
||||
<MyIcons
|
||||
:type="checkboxIconName"
|
||||
cursor="pointer"
|
||||
:size="16"
|
||||
class="checkbox"
|
||||
@click="mutipleSelectModel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type IProps = {
|
||||
size?: number
|
||||
mutipleMode?: boolean
|
||||
checkboxStatus?: boolean
|
||||
active: boolean
|
||||
modelInfo: IModelInfo
|
||||
}
|
||||
|
||||
import { ref } from 'vue'
|
||||
import MyIcons from '@/components/MyIcons.vue'
|
||||
import { type IModelInfo } from '@/interface'
|
||||
|
||||
const containerRef = ref(null)
|
||||
|
||||
const props = defineProps<IProps>()
|
||||
|
||||
// const { size = 15, active = false, modelInfo } = props
|
||||
|
||||
const checkboxIconName = computed(() =>
|
||||
props.mutipleMode && props.checkboxStatus ? 'selected' : 'noSelected'
|
||||
)
|
||||
|
||||
const { thumbnailFileUrl } = props.modelInfo
|
||||
|
||||
const width = `${props.size}px`
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectModel: [modelInfo: IModelInfo]
|
||||
deletetModel: [modelInfo: IModelInfo]
|
||||
modifyModel: [modelInfo: IModelInfo]
|
||||
onMutipleSelectModel: [modelInfo: IModelInfo, checkboxStatus: boolean]
|
||||
}>()
|
||||
|
||||
const selectModel = () => emit('selectModel', props.modelInfo)
|
||||
|
||||
const mutipleSelectModel = () => emit('onMutipleSelectModel', props.modelInfo, props.checkboxStatus)
|
||||
|
||||
const onDelete = () => {
|
||||
emit('deletetModel', props.modelInfo)
|
||||
}
|
||||
|
||||
const onModify = () => {
|
||||
emit('modifyModel', props.modelInfo)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$border1px: 0.5px solid #000;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.model-item-card {
|
||||
width: v-bind(width);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.active {
|
||||
border: $border1px;
|
||||
filter: grayscale(0) !important;
|
||||
|
||||
& :deep(.el-image) {
|
||||
display: block;
|
||||
font-size: 0;
|
||||
filter: grayscale(0%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: grayscale(0);
|
||||
|
||||
border: $border1px;
|
||||
|
||||
.tool-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& :deep(.el-image) {
|
||||
filter: grayscale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
& :deep(.el-image) {
|
||||
display: block;
|
||||
font-size: 0;
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tool-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
display: none;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
> .header,
|
||||
.footer {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
> span {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
// background-color: rgba(0, 0, 0, 0.5);
|
||||
font-size: 10px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
font-size: 12px;
|
||||
// background-color: red;
|
||||
}
|
||||
}
|
||||
|
||||
.mutiple {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 8;
|
||||
|
||||
.checkbox {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 10px;
|
||||
z-index: 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="my-button">{{ text }}</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
type IProps = {
|
||||
text: string
|
||||
type?: 'light' | 'dark'
|
||||
}
|
||||
|
||||
const { text, type = 'light' } = defineProps<IProps>()
|
||||
const lightStyle = reactive({ bgColor: 'rgba(255, 255, 255, 0.8)', fontColor: '#000' })
|
||||
|
||||
const darkStyle = reactive({ bgColor: 'rgba(0, 0, 0, 0.8)', fontColor: '#fff' })
|
||||
|
||||
const style = computed(() => (type === 'light' ? lightStyle : darkStyle))
|
||||
|
||||
const { fontColor, bgColor } = style.value
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.my-button {
|
||||
width: 90px;
|
||||
height: 36px;
|
||||
border: 0.5px solid #000;
|
||||
box-sizing: border-box;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
color: v-bind(fontColor);
|
||||
background-color: v-bind(bgColor);
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
font-weight: bolder;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="my-card">my-card</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.my-card {
|
||||
border: 1px solid #000;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="my-icons">
|
||||
<img :src="icon" alt="" class="icon" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import arrowDownIcon from '@/assets/imgs/arrow_down.png'
|
||||
import closeIcon from '@/assets/imgs/close.png'
|
||||
import hairIcon from '@/assets/imgs/hair_upload.png'
|
||||
import multipleBarIcon from '@/assets/imgs/multiple.png'
|
||||
import multipleActivatedBarIcon from '@/assets/imgs/multipleActivated.png'
|
||||
import noSelectedIcon from '@/assets/imgs/noSelected.png'
|
||||
import personIcon from '@/assets/imgs/person.png'
|
||||
import searchIcon from '@/assets/imgs/search.png'
|
||||
import selectedIcon from '@/assets/imgs/selected.png'
|
||||
import singleBarIcon from '@/assets/imgs/single.png'
|
||||
import singleActivatedBarIcon from '@/assets/imgs/singleActivated.png'
|
||||
import uploadIcon from '@/assets/imgs/upload.png'
|
||||
import uploadEnIcon from '@/assets/imgs/uploadEn.png'
|
||||
import videoPlayIcon from '@/assets/imgs/video_play.png'
|
||||
|
||||
import i18n from '@/i18n'
|
||||
|
||||
const { locale } = i18n.global
|
||||
|
||||
type IProps = {
|
||||
type:
|
||||
| 'single'
|
||||
| 'singleActivated'
|
||||
| 'multiple'
|
||||
| 'multipleActivated'
|
||||
| 'arrow'
|
||||
| 'preson'
|
||||
| 'selected'
|
||||
| 'noSelected'
|
||||
| 'close'
|
||||
| 'video'
|
||||
| 'upload'
|
||||
| 'search'
|
||||
size?: number
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
const props = defineProps<IProps>()
|
||||
const { type, size, cursor = 'default' } = toRefs(props)
|
||||
|
||||
const mapsRef = ref(
|
||||
new Map([
|
||||
['single', singleBarIcon],
|
||||
['singleActivated', singleActivatedBarIcon],
|
||||
['multiple', multipleBarIcon],
|
||||
['multipleActivated', multipleActivatedBarIcon],
|
||||
['arrow', arrowDownIcon],
|
||||
['preson', personIcon],
|
||||
['selected', selectedIcon],
|
||||
['noSelected', noSelectedIcon],
|
||||
['close', closeIcon],
|
||||
['video', videoPlayIcon],
|
||||
['upload', uploadIcon],
|
||||
['search', searchIcon],
|
||||
['hair', hairIcon]
|
||||
])
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.type === 'upload') {
|
||||
mapsRef.value.set('upload', locale.value == 'zh' ? uploadIcon : uploadEnIcon)
|
||||
}
|
||||
})
|
||||
|
||||
const icon = computed(() => mapsRef.value.get(type.value))
|
||||
|
||||
const height = `${size.value || 15}px`
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.my-icons {
|
||||
display: inline;
|
||||
|
||||
.icon {
|
||||
height: v-bind(height);
|
||||
cursor: v-bind(cursor);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,428 @@
|
||||
<template>
|
||||
<ScrollWithBar>
|
||||
<div class="pick-And-upload-image">
|
||||
<div class="tip">
|
||||
{{ $t('tips.upload') }} <span>{{ subNavText }}</span>
|
||||
</div>
|
||||
|
||||
<el-upload
|
||||
:fileList="fileList"
|
||||
:auto-upload="true"
|
||||
:headers="{ 'X-Access-Token': cookies.get('token') }"
|
||||
action="/api/cloth/uploadClothFile"
|
||||
multiple
|
||||
accept="image/*"
|
||||
class="custom-uploader"
|
||||
list-type="picture-card"
|
||||
:on-success="uploadSuccess"
|
||||
:before-upload="beforeUpload"
|
||||
ref="uploadRef"
|
||||
:limit="20"
|
||||
>
|
||||
<template #trigger>
|
||||
<div class="trigger-icon">
|
||||
<MyIcons :size="89" :type="nav.clothType == 'hair' ? 'hair' : 'upload'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #file="{ file }">
|
||||
<img class="thumbnail" :src="file.url" alt="" />
|
||||
|
||||
<span class="el-upload-list__item-actions">
|
||||
<span class="el-upload-list__item-delete" @click="handleRemove(file)">
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
<!-- <p class="prompt">(1-8张,效果越多越好)</p> -->
|
||||
<p class="prompt" v-if="nav.clothType == 'hair'">{{ $t('tips.uploadTip') }}</p>
|
||||
|
||||
<el-input
|
||||
:placeholder="$t('tips.placeholder')"
|
||||
size="large"
|
||||
class="link-input"
|
||||
v-model="linkText"
|
||||
>
|
||||
<template #prefix>
|
||||
<IconLink />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<div class="grabButton" @click="grabImage">{{ $t('tips.pickImg') }}</div>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<div class="grab-img-list">
|
||||
<el-row>
|
||||
<el-col
|
||||
v-for="(item, index) in grapImgList"
|
||||
:key="index"
|
||||
:lg="2"
|
||||
:md="4"
|
||||
:sm="6"
|
||||
:xl="2"
|
||||
:xs="12"
|
||||
>
|
||||
<div class="grap-image" @click="handleSelect(item)">
|
||||
<el-image :src="item.fileUrl" class="cover-img" fit="cover" lazy />
|
||||
|
||||
<span
|
||||
class="cover-icon"
|
||||
:class="{ active: fileList.some((i) => i.name === item.id) }"
|
||||
>
|
||||
<el-icon
|
||||
class="icon-item"
|
||||
v-if="fileList.some((i) => i.name === item.id)"
|
||||
color="#fff"
|
||||
>
|
||||
<SuccessFilled />
|
||||
</el-icon>
|
||||
</span>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="upload-btn">
|
||||
<el-checkbox
|
||||
size="small"
|
||||
v-model="isSyncClothesLibrary"
|
||||
@change="handleSyncToClothingLibrary"
|
||||
>{{ $t('tips.sync') }}</el-checkbox
|
||||
>
|
||||
<MyButton
|
||||
style="width: 151px; height: 36px; margin-left: 5px"
|
||||
:text="$t('tips.upload')"
|
||||
@click="handleUpload"
|
||||
></MyButton>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollWithBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CircleClose, SuccessFilled } from '@element-plus/icons-vue'
|
||||
|
||||
import { useCookies } from '@vueuse/integrations/useCookies'
|
||||
|
||||
import { ElMessageBox, ElLoading, type UploadUserFile, type UploadProps } from 'element-plus'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import IconLink from '@/components/icons/IconLink.vue'
|
||||
import MyButton from '@/components/MyButton.vue'
|
||||
import ScrollWithBar from '@/components/ScrollWithBar.vue'
|
||||
import { MENU_CONFIG } from '@/constants'
|
||||
import api from '@/plugins/axios'
|
||||
import { useGlobalStore } from '@/stores/globalStore'
|
||||
import { getLocalImageSize } from '@/utils'
|
||||
|
||||
const cookies = useCookies(['locale'])
|
||||
|
||||
const fileList = ref<UploadUserFile[]>([])
|
||||
|
||||
const grapImgList = ref<Record<'fileUrl' | 'id' | 'thumbnailFileUrl', string>[]>([])
|
||||
|
||||
const linkText = ref<string>()
|
||||
const isSyncClothesLibrary = ref<boolean>(true)
|
||||
const globalStore = useGlobalStore()
|
||||
const { menu, nav, gender } = storeToRefs(globalStore)
|
||||
|
||||
const emit = defineEmits<{
|
||||
onUploadSuccess: []
|
||||
}>()
|
||||
|
||||
type IStatus = {
|
||||
imgUploading: boolean
|
||||
}
|
||||
|
||||
const status = reactive<IStatus>({
|
||||
imgUploading: false
|
||||
})
|
||||
|
||||
const uploadRef = ref()
|
||||
|
||||
const handleRemove = (file: any) => {
|
||||
const name = file?.response?.result?.id || file?.name
|
||||
|
||||
const newFileList = fileList.value.filter((i) => i.name != name)
|
||||
|
||||
fileList.value = newFileList
|
||||
}
|
||||
|
||||
const subNavText = computed(
|
||||
() => MENU_CONFIG.value.find((item) => item.value === menu.value.subNav)?.name
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
// isSyncClothesLibrary.value = false
|
||||
fileList.value = []
|
||||
linkText.value = ''
|
||||
grapImgList.value = []
|
||||
}
|
||||
|
||||
// 切换tab 清空数据
|
||||
watch(subNavText, () => reset())
|
||||
|
||||
const grabImage = async () => {
|
||||
linkText.value && (await isValidURL(linkText.value))
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: '抓取中',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
})
|
||||
|
||||
const { result } = await api.grabPicture({ grabUrl: linkText.value })
|
||||
|
||||
grapImgList.value = result
|
||||
loading.close()
|
||||
}
|
||||
|
||||
const successListMarkRef = ref<any[]>([])
|
||||
|
||||
const uploadSuccess = (response: any, uploadFile: any, uploadFiles: any) => {
|
||||
successListMarkRef.value.push(uploadFile)
|
||||
|
||||
const { id: name, fileUrl: url } = response.result
|
||||
|
||||
fileList.value.push({ name, url })
|
||||
|
||||
const allUids = uploadFiles.map((i: any) => i.uid)
|
||||
|
||||
const successedUids = successListMarkRef.value.map((i) => i.uid)
|
||||
|
||||
status.imgUploading = !allUids.every((uid: number) => successedUids.includes(uid))
|
||||
}
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = async (rawFile) => {
|
||||
if (!rawFile.type.startsWith('image/')) {
|
||||
ElMessage.error('请选择正确的图片')
|
||||
return Promise.reject()
|
||||
}
|
||||
|
||||
const size = await getLocalImageSize(rawFile)
|
||||
|
||||
// console.log('AT-[ size xxxxxx &&&&&********** ]', size)
|
||||
|
||||
if (size.width < 500) {
|
||||
ElMessage.error('为确保效果,请上传500像素以上的图片')
|
||||
return Promise.reject()
|
||||
}
|
||||
|
||||
status.imgUploading = true
|
||||
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const handleSelect = async (item: any) => {
|
||||
console.log('AT-[ handleSelect &&&&&********** ]')
|
||||
// 判断图片是否已存在
|
||||
const existingFile = fileList.value.find((file) => item.fileUrl === file.url)
|
||||
|
||||
if (!existingFile) {
|
||||
// 手动上传
|
||||
const imageUrl = item.fileUrl
|
||||
const response = await fetch(imageUrl)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], item.id, { type: blob.type })
|
||||
uploadRef.value.handleStart(file)
|
||||
|
||||
// 添加到列表
|
||||
fileList.value.push({
|
||||
name: item.id,
|
||||
url: item.fileUrl
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleSyncToClothingLibrary = (val: any) => {
|
||||
isSyncClothesLibrary.value = val
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!status.imgUploading && !fileList.value.length) {
|
||||
ElMessage.warning('请先选择图片')
|
||||
return
|
||||
}
|
||||
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: '上传中',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
})
|
||||
|
||||
if (status.imgUploading) {
|
||||
while (status.imgUploading) {
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
}
|
||||
}
|
||||
|
||||
const clothFileList = fileList.value?.map((file) => file.name)
|
||||
|
||||
const clothType = nav.value.clothType as 'topCloth' | 'bottomCloth' | 'dress'
|
||||
|
||||
await api.addClothes({
|
||||
clothFileList,
|
||||
clothType,
|
||||
isSync: isSyncClothesLibrary.value ? 'yes' : 'no',
|
||||
sex: gender.value
|
||||
})
|
||||
|
||||
emit('onUploadSuccess')
|
||||
|
||||
loading.close()
|
||||
}
|
||||
|
||||
// expose
|
||||
|
||||
/**
|
||||
* 输入链接校验
|
||||
*/
|
||||
const isValidURL = (url: string) => {
|
||||
try {
|
||||
const parsedURL = new URL(url)
|
||||
const hostname = parsedURL.hostname
|
||||
const domain = hostname.split('.').slice(-2).join('.')
|
||||
return domain === 'vip.com'
|
||||
} catch {
|
||||
ElMessageBox({
|
||||
type: 'error',
|
||||
message: '请输入正确的链接'
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pick-And-upload-image {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-right: 0.5px solid #000;
|
||||
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: #000;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -5px;
|
||||
width: 9px;
|
||||
height: 1px;
|
||||
background-color: #000;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
& :deep(.el-upload--picture-card) {
|
||||
--el-upload-picture-card-size: 89px;
|
||||
}
|
||||
|
||||
& :deep(.el-upload-list) {
|
||||
--el-upload-list-picture-card-size: 89px;
|
||||
}
|
||||
|
||||
& :deep(.el-upload-list__item) {
|
||||
border-radius: 0;
|
||||
border: 0.5px solid #000;
|
||||
}
|
||||
|
||||
.trigger-icon {
|
||||
width: 89px;
|
||||
height: 89px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 89px;
|
||||
height: 89px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
// background-color: red;
|
||||
// border-width: 0.5px;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
font-size: 12px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.link-input {
|
||||
max-width: 580px;
|
||||
width: 100%;
|
||||
border: 0.5px solid #000000;
|
||||
margin-top: 10px;
|
||||
|
||||
.grabButton {
|
||||
cursor: pointer;
|
||||
color: #000000;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.grab-img-list {
|
||||
margin-top: 10px;
|
||||
height: auto;
|
||||
|
||||
.grap-image {
|
||||
position: relative;
|
||||
border: 0.5px solid #000;
|
||||
.cover-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
display: none;
|
||||
|
||||
.icon-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .cover-icon {
|
||||
opacity: 1;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
text-align: center;
|
||||
|
||||
& :deep(.el-checkbox__label) {
|
||||
color: #87909e;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
& :deep(.el-checkbox__inner) {
|
||||
border-color: #87909e;
|
||||
background-color: #87909e;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<swiper
|
||||
class="swiper"
|
||||
:modules="modules"
|
||||
direction="vertical"
|
||||
slides-per-view="auto"
|
||||
:free-mode="true"
|
||||
:scrollbar="true"
|
||||
:mousewheel="true"
|
||||
:auto-height="true"
|
||||
>
|
||||
<swiper-slide class="slide">
|
||||
<slot></slot>
|
||||
</swiper-slide>
|
||||
</swiper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Pagination, FreeMode, Scrollbar, Mousewheel } from 'swiper/modules'
|
||||
import { Swiper, SwiperSlide } from 'swiper/vue'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/pagination'
|
||||
import 'swiper/css/free-mode'
|
||||
import 'swiper/css/scrollbar'
|
||||
|
||||
const modules = [Pagination, FreeMode, Scrollbar, Mousewheel]
|
||||
|
||||
const height = '10000000000px'
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.swiper-scrollbar) {
|
||||
width: 0.5px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
:deep(.swiper-scrollbar-drag) {
|
||||
width: 0.5px;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: v-bind(height);
|
||||
background-color: black;
|
||||
}
|
||||
}
|
||||
|
||||
.swiper {
|
||||
height: 100%;
|
||||
|
||||
.slide {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="search-input">
|
||||
<MyIcons type="search" :size="15" style="display: inline-flex" />
|
||||
<span>{{ $t('nav.search') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-input {
|
||||
width: 123px;
|
||||
height: 22px;
|
||||
border: 1px solid #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
font-size: 10px;
|
||||
box-sizing: border-box;
|
||||
|
||||
// background-color: red;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToggle } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useGlobalStore } from '@/stores/globalStore'
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
const { menu } = storeToRefs(globalStore)
|
||||
const emit = defineEmits(['change'])
|
||||
const props = defineProps<{
|
||||
checked?: boolean
|
||||
}>()
|
||||
console.log(props.checked, '饿', menu.value)
|
||||
const [value, toggle] = useToggle(props.checked)
|
||||
const handleToggle = () => {
|
||||
toggle()
|
||||
emit('change', value.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="singleCheckbox" @click="handleToggle">
|
||||
<div v-if="!value">
|
||||
<svg
|
||||
fill="none"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
width="15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
fill="#D9D9D9"
|
||||
height="14.5"
|
||||
stroke="black"
|
||||
stroke-width="0.5"
|
||||
width="14.5"
|
||||
x="0.25"
|
||||
y="0.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else>
|
||||
<svg
|
||||
fill="none"
|
||||
height="15"
|
||||
viewBox="0 0 18 15"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
fill="#D9D9D9"
|
||||
height="14.5"
|
||||
stroke="black"
|
||||
stroke-width="0.5"
|
||||
width="14.5"
|
||||
x="0.25"
|
||||
y="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M2.91675 5.41666L6.66675 10.8333L17.0834 0.416656"
|
||||
stroke="black"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.singleCheckbox {
|
||||
width: 16px;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> +
|
||||
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
|
||||
you need to test your components and web pages, check out
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and
|
||||
<a href="https://on.cypress.io/component" target="_blank" rel="noopener"
|
||||
>Cypress Component Testing</a
|
||||
>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in <code>README.md</code>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
|
||||
Discord server, or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also subscribe to
|
||||
<a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow
|
||||
the official
|
||||
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
twitter account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts" setup>
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { ref } from 'vue'
|
||||
import IconLink from '@/components/icons/IconLink.vue'
|
||||
|
||||
const emit = defineEmits(['grabImage'])
|
||||
const input = ref('')
|
||||
const grabImage = () => {
|
||||
// console.log(input.value)
|
||||
// input.value && emit('grabImage', input.value)
|
||||
|
||||
const isOk = isValidURL(input.value)
|
||||
|
||||
if (isOk) {
|
||||
emit('grabImage', input.value)
|
||||
} else {
|
||||
ElMessageBox({
|
||||
type: 'error',
|
||||
message: '请输入正确的链接'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isValidURL = (url: string) => {
|
||||
try {
|
||||
const parsedURL = new URL(url)
|
||||
const hostname = parsedURL.hostname
|
||||
const domain = hostname.split('.').slice(-2).join('.')
|
||||
return domain === 'vip.com'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vpShop-link">
|
||||
<el-input
|
||||
v-model="input"
|
||||
placeholder="输入唯品会链接自动抓取图片"
|
||||
size="large"
|
||||
style="max-width: 580px; width: 100%; border: 1px solid #000000"
|
||||
>
|
||||
<template #prefix>
|
||||
<IconLink />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<div class="grabButton" @click="grabImage">抓取图片</div>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vpShop-link {
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.grabButton {
|
||||
cursor: pointer;
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.grabButton {
|
||||
cursor: pointer;
|
||||
color: #000000;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="18" height="15" viewBox="0 0 18 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.25" y="0.25" width="14.5" height="14.5" fill="#D9D9D9" stroke="black" stroke-width="0.5"/>
|
||||
<path d="M2.91675 5.41666L6.66675 10.8333L17.0834 0.416656" stroke="black" stroke-width="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 301 B |
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<svg width="1em" height="1em" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1743_871)">
|
||||
<path
|
||||
d="M9.625 11H1.375C0.61875 11 0 10.3813 0 9.625V1.375C0 0.61875 0.61875 0 1.375 0H9.625C10.3813 0 11 0.61875 11 1.375V9.625C11 10.3813 10.3813 11 9.625 11ZM1.375 0.6875C0.9625 0.6875 0.6875 0.9625 0.6875 1.375V9.625C0.6875 10.0375 0.9625 10.3125 1.375 10.3125H9.625C10.0375 10.3125 10.3125 10.0375 10.3125 9.625V1.375C10.3125 0.9625 10.0375 0.6875 9.625 0.6875H1.375Z"
|
||||
fill="#2C2C2C"
|
||||
/>
|
||||
<path
|
||||
d="M8.25 5.84375H2.75C2.54375 5.84375 2.40625 5.70625 2.40625 5.5C2.40625 5.29375 2.54375 5.15625 2.75 5.15625H8.25C8.45625 5.15625 8.59375 5.29375 8.59375 5.5C8.59375 5.70625 8.45625 5.84375 8.25 5.84375Z"
|
||||
fill="#2C2C2C"
|
||||
/>
|
||||
<path
|
||||
d="M5.5 8.59375C5.29375 8.59375 5.15625 8.45625 5.15625 8.25V2.75C5.15625 2.54375 5.29375 2.40625 5.5 2.40625C5.70625 2.40625 5.84375 2.54375 5.84375 2.75V8.25C5.84375 8.45625 5.70625 8.59375 5.5 8.59375Z"
|
||||
fill="#2C2C2C"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1743_871">
|
||||
<rect width="11" height="11" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<svg width="1em" height="1em" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1991_465)">
|
||||
<path
|
||||
d="M6.53009 8.25046C6.33977 8.25046 6.1864 8.09709 6.1864 7.90676V4.1243C6.1864 3.93398 6.33977 3.78061 6.53009 3.78061C6.72042 3.78061 6.87379 3.93398 6.87379 4.1243V7.90492C6.87379 8.09524 6.72042 8.25046 6.53009 8.25046Z"
|
||||
fill="#2C2C2C"
|
||||
/>
|
||||
<path
|
||||
d="M4.46796 8.25046C4.27764 8.25046 4.12427 8.09709 4.12427 7.90676V4.1243C4.12427 3.93398 4.27764 3.78061 4.46796 3.78061C4.65828 3.78061 4.81165 3.93398 4.81165 4.1243V7.90492C4.81165 8.09524 4.65828 8.25046 4.46796 8.25046Z"
|
||||
fill="#2C2C2C"
|
||||
/>
|
||||
<path
|
||||
d="M9.96892 2.40584H8.25046V1.71845C8.25046 1.14933 7.7922 0.687378 7.22677 0.687378H3.78061C3.21148 0.687378 2.74953 1.14933 2.74953 1.71845V2.40584H1.03107C0.840746 2.40584 0.687378 2.55921 0.687378 2.74953C0.687378 2.93986 0.840746 3.09322 1.03107 3.09322H9.96892C10.1592 3.09322 10.3126 2.93986 10.3126 2.74953C10.3126 2.55921 10.1592 2.40584 9.96892 2.40584ZM3.43692 1.71845C3.43692 1.52998 3.59213 1.37476 3.78061 1.37476H7.22677C7.41525 1.37476 7.56307 1.52628 7.56307 1.71845V2.40584H3.43692V1.71845Z"
|
||||
fill="#2C2C2C"
|
||||
/>
|
||||
<path
|
||||
d="M7.90673 10.3126H3.09504C2.52592 10.3126 2.06396 9.85066 2.06396 9.28154V4.12061C2.06396 3.93028 2.21733 3.77692 2.40766 3.77692C2.59798 3.77692 2.75135 3.93028 2.75135 4.12061V9.28154C2.75135 9.47186 2.90657 9.62523 3.09504 9.62523H7.90858C8.09891 9.62523 8.25227 9.47186 8.25227 9.28154V4.13354C8.25227 3.94322 8.40564 3.78985 8.59597 3.78985C8.78629 3.78985 8.93966 3.94322 8.93966 4.13354V9.28154C8.93781 9.84881 8.47586 10.3126 7.90673 10.3126Z"
|
||||
fill="#2C2C2C"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1991_465">
|
||||
<rect width="11" height="11" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<svg width="16" height="4" viewBox="0 0 16 4" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect y="0.00012207" width="4" height="4" fill="black" />
|
||||
<rect y="0.00012207" width="4" height="4" fill="black" />
|
||||
<rect y="0.00012207" width="4" height="4" fill="black" />
|
||||
<rect x="6" y="0.00012207" width="4" height="4" fill="black" />
|
||||
<rect x="6" y="0.00012207" width="4" height="4" fill="black" />
|
||||
<rect x="6" y="0.00012207" width="4" height="4" fill="black" />
|
||||
<rect x="12" y="0.00012207" width="4" height="4" fill="black" />
|
||||
<rect x="12" y="0.00012207" width="4" height="4" fill="black" />
|
||||
<rect x="12" y="0.00012207" width="4" height="4" fill="black" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg width="1em" height="1em" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M8.26743 0.246544C8.29914 0.213713 8.33707 0.187525 8.379 0.16951C8.42094 0.151494 8.46605 0.142011 8.51169 0.141615C8.55734 0.141218 8.6026 0.149915 8.64485 0.167199C8.68709 0.184483 8.72547 0.210008 8.75775 0.242284C8.79002 0.274559 8.81555 0.31294 8.83283 0.355185C8.85012 0.397431 8.85881 0.442696 8.85842 0.488338C8.85802 0.533981 8.84854 0.579088 8.83052 0.621027C8.81251 0.662966 8.78632 0.700897 8.75349 0.732607L3.89252 5.59369C3.82805 5.65816 3.74061 5.69438 3.64943 5.69438C3.55826 5.69438 3.47081 5.65816 3.40634 5.59369C3.34187 5.52922 3.30565 5.44178 3.30565 5.3506C3.30565 5.25943 3.34187 5.17198 3.40634 5.10751L8.26743 0.246659V0.246544ZM8.16671 3.81249C8.16671 3.72132 8.20292 3.63389 8.26739 3.56942C8.33185 3.50496 8.41929 3.46874 8.51046 3.46874C8.60162 3.46874 8.68906 3.50496 8.75352 3.56942C8.81799 3.63389 8.85421 3.72132 8.85421 3.81249V7.59374C8.85421 8.28983 8.28988 8.85416 7.59379 8.85416H1.40629C0.710197 8.85416 0.145874 8.28983 0.145874 7.59374V1.40624C0.145874 0.710148 0.710197 0.145826 1.40629 0.145826H5.07296C5.16412 0.145826 5.25156 0.182042 5.31602 0.246508C5.38049 0.310973 5.41671 0.398408 5.41671 0.489576C5.41671 0.580744 5.38049 0.668178 5.31602 0.732644C5.25156 0.797109 5.16412 0.833326 5.07296 0.833326H1.40629C1.25434 0.833326 1.10862 0.893686 1.00118 1.00113C0.893735 1.10857 0.833374 1.2543 0.833374 1.40624V7.59374C0.833374 7.74569 0.893735 7.89141 1.00118 7.99885C1.10862 8.1063 1.25434 8.16666 1.40629 8.16666H7.59379C7.74574 8.16666 7.89146 8.1063 7.9989 7.99885C8.10635 7.89141 8.16671 7.74569 8.16671 7.59374V3.81249Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<svg width="1em" height="1em" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M1.85732 5.76748V2.16777C1.85732 1.99053 2.00234 1.84658 2.18174 1.84658H7.00283C7.18223 1.84658 7.32725 1.99053 7.32725 2.16777V5.76855C7.32725 5.9458 7.18223 6.08975 7.00283 6.08975H5.35928C5.19814 6.08975 5.06709 6.2208 5.06709 6.38193V6.44102C5.06709 6.60215 5.19814 6.7332 5.35928 6.7332H7.32617C7.68389 6.7332 7.975 6.44531 7.975 6.08975V1.84658C7.975 1.49102 7.68496 1.20312 7.32617 1.20312H1.53291C1.35352 1.20312 1.2085 1.34707 1.2085 1.52432V6.08867C1.2085 6.44424 1.49854 6.73213 1.85732 6.73213H2.18174C2.34287 6.73213 2.47393 6.60107 2.47393 6.43994V6.38086C2.47393 6.21973 2.34287 6.08867 2.18174 6.08867C2.00234 6.08975 1.85732 5.9458 1.85732 5.76748Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M9.15342 4.26788H8.829C8.68613 4.26788 8.56904 4.3839 8.56904 4.52784V4.65138C8.56904 4.79425 8.68506 4.91134 8.829 4.91134C9.0084 4.91134 9.15342 5.05529 9.15342 5.23253V8.83331C9.15342 9.01056 9.0084 9.1545 8.829 9.1545H3.97568C3.79629 9.1545 3.65127 9.01056 3.65127 8.83331V5.23253C3.65127 5.05529 3.79629 4.91134 3.97568 4.91134H5.68369C5.82656 4.91134 5.94365 4.79532 5.94365 4.65138V4.52784C5.94365 4.38497 5.82764 4.26788 5.68369 4.26788H3.65127C3.29355 4.26788 3.00244 4.55577 3.00244 4.91134V9.1545C3.00244 9.51007 3.29248 9.79796 3.65127 9.79796H9.15342C9.51113 9.79796 9.80225 9.51007 9.80225 9.1545V4.91134C9.80225 4.55577 9.51221 4.26788 9.15342 4.26788Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg width="123" height="21" viewBox="0 0 123 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="122" height="20" fill="white" stroke="black"/>
|
||||
<g clip-path="url(#clip0_1650_2387)">
|
||||
<path d="M21.0001 17.39L16.5551 12.94C17.5712 11.7647 18.0917 10.2411 18.0072 8.68974C17.9228 7.13842 17.2398 5.68034 16.1021 4.62232C14.9644 3.5643 13.4607 2.98884 11.9073 3.01701C10.3539 3.04518 8.87205 3.6748 7.77347 4.77338C6.67489 5.87196 6.04528 7.35384 6.0171 8.90722C5.98893 10.4606 6.56439 11.9643 7.62241 13.102C8.68043 14.2397 10.1385 14.9227 11.6898 15.0071C13.2412 15.0916 14.7648 14.5711 15.9401 13.555L20.3901 18L21.0001 17.39ZM6.8401 9.00001C6.83911 7.97684 7.14174 6.97638 7.70967 6.12531C8.27759 5.27423 9.08528 4.61081 10.0305 4.21903C10.9757 3.82726 12.0158 3.72475 13.0193 3.92448C14.0228 4.12421 14.9445 4.6172 15.6676 5.34104C16.3907 6.06488 16.8828 6.98701 17.0816 7.99069C17.2804 8.99437 17.1768 10.0345 16.7842 10.9793C16.3915 11.9241 15.7273 12.7311 14.8756 13.2982C14.024 13.8653 13.0233 14.167 12.0001 14.165C10.6311 14.1637 9.31867 13.6189 8.35112 12.6504C7.38358 11.682 6.8401 10.369 6.8401 9.00001Z" fill="black"/>
|
||||
</g>
|
||||
<path d="M97.55 5.97L98.1 6.47C97.59 6.77 96.93 7.03 96.37 7.21C96.33 7.07 96.23 6.85 96.14 6.7C96.65 6.51 97.22 6.23 97.55 5.97ZM96 6.49L96.66 6.7V9.92H96V6.49ZM96 9.58H100.52V7.04H99.25V6.43H101.2V10.2H96V9.58ZM96.29 8.01H97.75V8.58H96.29V8.01ZM99.29 8.01H100.82V8.58H99.29V8.01ZM98.2 5.61H98.88V11.25H98.2V5.61ZM95.73 11.11H100.64V11.75H95.73V11.11ZM100.43 11.11H100.57L100.7 11.08L101.17 11.31C100.22 13.44 97.94 14.42 95.49 14.82C95.43 14.63 95.29 14.34 95.16 14.18C97.45 13.88 99.64 13 100.43 11.22V11.11ZM97.04 11.68C97.85 13.02 99.6 13.88 101.7 14.21C101.53 14.37 101.33 14.66 101.23 14.87C99.08 14.46 97.32 13.48 96.38 11.93L97.04 11.68ZM92.28 10.92C93.14 10.7 94.42 10.32 95.66 9.96L95.75 10.64C94.61 11 93.41 11.38 92.48 11.65L92.28 10.92ZM92.41 7.62H95.65V8.32H92.41V7.62ZM93.8 5.61H94.53V13.94C94.53 14.35 94.43 14.56 94.17 14.67C93.91 14.79 93.51 14.81 92.84 14.8C92.82 14.62 92.71 14.32 92.62 14.12C93.07 14.13 93.49 14.13 93.61 14.13C93.75 14.13 93.8 14.08 93.8 13.94V5.61ZM107.6 12.05H108.35V14.82H107.6V12.05ZM107.6 5.6H108.34V8.4H107.6V5.6ZM107.23 8.58L107.93 8.92C107.35 9.46 106.64 10.04 106.06 10.44L105.51 10.13C106.07 9.72 106.82 9.06 107.23 8.58ZM109.39 9.23L110.07 9.6C108.94 10.52 107.4 11.58 106.18 12.26L105.65 11.9C106.87 11.21 108.45 10.08 109.39 9.23ZM104.74 10.1L105.24 9.67C105.99 10.04 106.94 10.61 107.43 11.03L106.91 11.51C106.45 11.09 105.51 10.49 104.74 10.1ZM109.28 10.89L109.78 10.49C110.66 11.01 111.77 11.78 112.33 12.31L111.8 12.77C111.27 12.24 110.17 11.42 109.28 10.89ZM109.31 13.03L109.88 12.64C110.74 13.06 111.84 13.73 112.41 14.22L111.79 14.67C111.27 14.18 110.19 13.48 109.31 13.03ZM103.65 11.74C105.48 11.74 108.5 11.7 111.35 11.64L111.31 12.24C108.54 12.32 105.59 12.41 103.69 12.43L103.65 11.74ZM105.89 12.63L106.6 12.89C105.93 13.58 104.9 14.27 104.03 14.7C103.9 14.56 103.62 14.3 103.44 14.17C104.33 13.8 105.29 13.22 105.89 12.63ZM103.76 6.49H112.21V7.15H103.76V6.49ZM103.73 8.11H112.3V9.99H111.56V8.76H104.44V9.99H103.73V8.11Z" fill="black"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_1650_2387">
|
||||
<rect width="15" height="15" fill="white" transform="translate(6 3)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.25" y="0.25" width="14.5" height="14.5" fill="#D9D9D9" stroke="black" stroke-width="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 205 B |
@@ -0,0 +1,82 @@
|
||||
import { MenuEnum } from '@/enum/MenuEnum'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
const { t } = i18n.global
|
||||
|
||||
export const MENU_CONFIG = computed(() => {
|
||||
return [
|
||||
{
|
||||
name: t(`nav.menus.all`),
|
||||
value: MenuEnum.ALL,
|
||||
status: true
|
||||
},
|
||||
|
||||
{
|
||||
name: t(`nav.menus.tops`),
|
||||
value: MenuEnum.UPPER_OUTER_GARMENT,
|
||||
status: true
|
||||
},
|
||||
|
||||
{
|
||||
name: t(`nav.menus.bottoms`),
|
||||
value: MenuEnum.BOTTOMS,
|
||||
status: true
|
||||
},
|
||||
|
||||
{
|
||||
name: t(`nav.menus.dresses`),
|
||||
value: MenuEnum.DRESS,
|
||||
status: true
|
||||
},
|
||||
|
||||
{
|
||||
name: t(`nav.menus.sets`),
|
||||
value: MenuEnum.SUIT,
|
||||
status: true
|
||||
},
|
||||
|
||||
{
|
||||
name: t(`nav.menus.hairstyles`),
|
||||
value: MenuEnum.HAIRSTYLE,
|
||||
status: true
|
||||
},
|
||||
|
||||
{
|
||||
name: t(`nav.menus.figure`),
|
||||
value: MenuEnum.FIGURE,
|
||||
status: true
|
||||
},
|
||||
{
|
||||
name: t(`nav.menus.underwear`),
|
||||
value: MenuEnum.UNDERWEAR,
|
||||
status: true
|
||||
},
|
||||
|
||||
{
|
||||
name: t(`nav.menus.coats`),
|
||||
value: MenuEnum.COAT,
|
||||
status: false
|
||||
},
|
||||
{
|
||||
name: t(`nav.menus.accessories`),
|
||||
value: MenuEnum.ACCESSORIES,
|
||||
status: false
|
||||
},
|
||||
|
||||
{
|
||||
name: t(`nav.menus.shoesAndSocks`),
|
||||
value: MenuEnum.SHOES_AND_SOCKS,
|
||||
status: false
|
||||
},
|
||||
{
|
||||
name: t(`nav.menus.backgrounds`),
|
||||
value: MenuEnum.BACKGROUND,
|
||||
status: false
|
||||
},
|
||||
{
|
||||
name: t(`nav.menus.lighting`),
|
||||
value: MenuEnum.LIGHT,
|
||||
status: false
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum ApiEnum {
|
||||
LOGIN = 'auth/login',
|
||||
REGISTER = 'auth/register',
|
||||
FORGOT_PASSWORD = 'auth/forgot-password',
|
||||
CURRENT_USER = 'user/current',
|
||||
BASE_CONFIG = 'config/base',
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum CacheEnum {
|
||||
TOKEN_NAME = 'login_token',
|
||||
REDIRECT_ROUTE_NAME = 'redirect_router_name',
|
||||
HISTORY_MENU = 'history_menu',
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export enum HttpCodeEnum {
|
||||
CONTINUE = 100,
|
||||
SWITCHING_PROTOCOLS = 101,
|
||||
PROCESSING = 102,
|
||||
EARLYHINTS = 103,
|
||||
OK = 200,
|
||||
CREATED = 201,
|
||||
ACCEPTED = 202,
|
||||
NON_AUTHORITATIVE_INFORMATION = 203,
|
||||
NO_CONTENT = 204,
|
||||
RESET_CONTENT = 205,
|
||||
PARTIAL_CONTENT = 206,
|
||||
AMBIGUOUS = 300,
|
||||
MOVED_PERMANENTLY = 301,
|
||||
FOUND = 302,
|
||||
SEE_OTHER = 303,
|
||||
NOT_MODIFIED = 304,
|
||||
TEMPORARY_REDIRECT = 307,
|
||||
PERMANENT_REDIRECT = 308,
|
||||
BAD_REQUEST = 400,
|
||||
UNAUTHORIZED = 401,
|
||||
PAYMENT_REQUIRED = 402,
|
||||
FORBIDDEN = 403,
|
||||
NOT_FOUND = 404,
|
||||
METHOD_NOT_ALLOWED = 405,
|
||||
NOT_ACCEPTABLE = 406,
|
||||
PROXY_AUTHENTICATION_REQUIRED = 407,
|
||||
REQUEST_TIMEOUT = 408,
|
||||
CONFLICT = 409,
|
||||
GONE = 410,
|
||||
LENGTH_REQUIRED = 411,
|
||||
PRECONDITION_FAILED = 412,
|
||||
PAYLOAD_TOO_LARGE = 413,
|
||||
URI_TOO_LONG = 414,
|
||||
UNSUPPORTED_MEDIA_TYPE = 415,
|
||||
REQUESTED_RANGE_NOT_SATISFIABLE = 416,
|
||||
EXPECTATION_FAILED = 417,
|
||||
I_AM_A_TEAPOT = 418,
|
||||
MISDIRECTED = 421,
|
||||
UNPROCESSABLE_ENTITY = 422,
|
||||
FAILED_DEPENDENCY = 424,
|
||||
PRECONDITION_REQUIRED = 428,
|
||||
TOO_MANY_REQUESTS = 429,
|
||||
INTERNAL_SERVER_ERROR = 500,
|
||||
NOT_IMPLEMENTED = 501,
|
||||
BAD_GATEWAY = 502,
|
||||
SERVICE_UNAVAILABLE = 503,
|
||||
GATEWAY_TIMEOUT = 504,
|
||||
HTTP_VERSION_NOT_SUPPORTED = 505,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export enum MenuEnum {
|
||||
/**
|
||||
* 服装库
|
||||
*/
|
||||
CLOTHES_LIBRARY = 'CLOTHES_LIBRARY',
|
||||
|
||||
/**
|
||||
* 我的衣橱
|
||||
*/
|
||||
MY_WARDROBE = 'MY_WARDROBE',
|
||||
/**
|
||||
* 全部
|
||||
*/
|
||||
ALL = 'ALL',
|
||||
UPPER_OUTER_GARMENT = 'UPPER_OUTER_GARMENT',
|
||||
BOTTOMS = 'BOTTOMS',
|
||||
ACCESSORIES = 'ACCESSORIES',
|
||||
DRESS = 'DRESS',
|
||||
SUIT = 'SUIT',
|
||||
COAT = 'COAT',
|
||||
HAIRSTYLE = 'HAIRSTYLE',
|
||||
SHOES_AND_SOCKS = 'SHOES_AND_SOCKS',
|
||||
BACKGROUND = 'BACKGROUND',
|
||||
LIGHT = 'LIGHT',
|
||||
FIGURE = 'FIGURE',
|
||||
UNDERWEAR = 'UNDERWEAR'
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import en from './locale/en'
|
||||
import zh from './locale/zh'
|
||||
|
||||
const u18n = createI18n({
|
||||
locale: 'zh',
|
||||
|
||||
fallbackLocale: 'zh',
|
||||
|
||||
legacy: false,
|
||||
|
||||
messages: {
|
||||
en,
|
||||
zh
|
||||
}
|
||||
})
|
||||
|
||||
export default u18n
|
||||
@@ -0,0 +1,74 @@
|
||||
export default {
|
||||
userInfo: {
|
||||
name: `User {username}'s Virtual Fitting Room`
|
||||
},
|
||||
|
||||
nav: {
|
||||
menus: {
|
||||
all: 'All',
|
||||
tops: 'Tops',
|
||||
bottoms: 'Bottoms',
|
||||
dresses: 'Dresses',
|
||||
sets: 'Sets',
|
||||
coats: 'Coats',
|
||||
accessories: 'Accessories',
|
||||
hairstyles: 'Hairstyles',
|
||||
figure: 'figure',
|
||||
underwear: 'underwear',
|
||||
shoesAndSocks: 'Shoes & Socks',
|
||||
backgrounds: 'Backgrounds',
|
||||
lighting: 'Lighting'
|
||||
},
|
||||
|
||||
MENU_NAMES: [
|
||||
'All',
|
||||
'Tops',
|
||||
'Bottoms',
|
||||
'Dresses',
|
||||
'Sets',
|
||||
'Coats',
|
||||
'Accessories',
|
||||
'Hairstyles',
|
||||
'Figure',
|
||||
'Underwear',
|
||||
'Shoes & Socks',
|
||||
'Backgrounds',
|
||||
'Lighting'
|
||||
],
|
||||
|
||||
clothLib: 'Clothing Library',
|
||||
myCloset: 'My Closet',
|
||||
search: 'Search'
|
||||
},
|
||||
|
||||
tips: {
|
||||
addToCloset: 'Add to Closet',
|
||||
select: 'Select',
|
||||
delete: 'Delete',
|
||||
buyLink: 'Purchase Link',
|
||||
more: 'Add pics',
|
||||
training: 'Training Your Uploads',
|
||||
cancel: 'Remove from Closet',
|
||||
upload: 'Upload',
|
||||
placeholder: 'Enter Link for Automatic Image Retrieval',
|
||||
pickImg: 'Retrieve Image',
|
||||
sync: 'Sync to Clothing Library',
|
||||
tryonVideo: 'Try-On Video',
|
||||
tryonPic: 'HD Try-On',
|
||||
tryonFast: 'Quick Try-On',
|
||||
download: 'Download',
|
||||
addToSet: 'Add to Set',
|
||||
selectAll: 'Select All',
|
||||
modifyFace: 'Facial Editing',
|
||||
seek: 'For any queries or issues, kindly add our WeChat: feastfu',
|
||||
warning:
|
||||
'The feature requires a paid subscription. Please add our product WeChat for consultation: feastfu',
|
||||
inputBuyLink: 'Please enter the purchase link',
|
||||
confirmText: 'Confirm',
|
||||
cancelText: 'Cancel',
|
||||
inputErrorMessage: 'Please enter a valid link address',
|
||||
processing: `Processing {time} seconds remaining`,
|
||||
preview: 'Preview',
|
||||
uploadTip: '(1-8 sheets, the more the better the effect)'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export default {
|
||||
userInfo: {
|
||||
name: '用户{username}的试衣间'
|
||||
},
|
||||
|
||||
nav: {
|
||||
menus: {
|
||||
all: '全部',
|
||||
tops: '上衣',
|
||||
bottoms: '下衣',
|
||||
dresses: '连衣裙',
|
||||
sets: '套装',
|
||||
coats: '外套',
|
||||
accessories: '配饰',
|
||||
hairstyles: '发型',
|
||||
figure: '身材',
|
||||
underwear: '内衣',
|
||||
shoesAndSocks: '鞋袜',
|
||||
backgrounds: '背景',
|
||||
lighting: '灯光'
|
||||
},
|
||||
|
||||
MENU_NAMES: [
|
||||
'全部',
|
||||
'上衣',
|
||||
'下衣',
|
||||
'连衣裙',
|
||||
'套装',
|
||||
'外套',
|
||||
'配饰',
|
||||
'发型',
|
||||
'身材',
|
||||
'内衣',
|
||||
'鞋袜',
|
||||
'背景',
|
||||
'灯光'
|
||||
],
|
||||
|
||||
clothLib: '衣服库',
|
||||
myCloset: '我的衣橱',
|
||||
search: '搜索'
|
||||
},
|
||||
|
||||
tips: {
|
||||
addToCloset: '加入衣橱',
|
||||
select: '选择',
|
||||
delete: '删除',
|
||||
buyLink: '购买链接',
|
||||
more: '上传多图',
|
||||
training: '模型训练中',
|
||||
cancel: '取消加入',
|
||||
upload: '上传',
|
||||
placeholder: '输入链接自动抓取图片',
|
||||
pickImg: '抓取图片',
|
||||
sync: '同步到衣服库',
|
||||
tryonVideo: '视频试穿',
|
||||
tryonPic: '高清试穿',
|
||||
tryonFast: '极速试穿',
|
||||
download: '下载',
|
||||
addToSet: '加入套装',
|
||||
selectAll: '全选',
|
||||
modifyFace: '面部修改',
|
||||
seek: '咨询/投诉请添加产品微信:feastfu',
|
||||
warning: '该功能需付费使用,请添加产品微信咨询:feastfu”',
|
||||
inputBuyLink: '请输入购买链接',
|
||||
confirmText: '确认',
|
||||
cancelText: '取消',
|
||||
inputErrorMessage: '请输入正确的链接地址',
|
||||
processing: `加工{time}秒`,
|
||||
preview: '预览',
|
||||
uploadTip: '(1-8张发型图片,越多效果越好)'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MenuEnum } from '@/enum/MenuEnum'
|
||||
|
||||
export type IClothType =
|
||||
| 'topCloth'
|
||||
| 'bottomCloth'
|
||||
| 'dress'
|
||||
| 'suit'
|
||||
| 'hair'
|
||||
| 'figure'
|
||||
| 'underwear'
|
||||
|
||||
export type IClotheInfo = {
|
||||
suitList?: [IClotheInfo, IClotheInfo]
|
||||
clothId: string
|
||||
fileUrl: string
|
||||
isHas: 'yes' | 'no'
|
||||
clothType: IClothType
|
||||
thumbnailFileUrl: string
|
||||
fileStatus: 'disable' | 'enable'
|
||||
buyLink: string | null
|
||||
}
|
||||
|
||||
export type IClotheList = IClotheInfo[]
|
||||
|
||||
export type iGenderOptions = 'male' | 'female'
|
||||
|
||||
export type IModelInfo = {
|
||||
fileUrl: string
|
||||
id: string
|
||||
thumbnailFileUrl: string
|
||||
}
|
||||
|
||||
export type IMainNav = MenuEnum.CLOTHES_LIBRARY | MenuEnum.MY_WARDROBE
|
||||
|
||||
export type ISubNav =
|
||||
| MenuEnum.ALL
|
||||
| MenuEnum.UPPER_OUTER_GARMENT
|
||||
| MenuEnum.BOTTOMS
|
||||
| MenuEnum.DRESS
|
||||
| MenuEnum.SUIT
|
||||
| MenuEnum.HAIRSTYLE
|
||||
| MenuEnum.FIGURE
|
||||
| MenuEnum.UNDERWEAR
|
||||
|
||||
export type INavStatus = {
|
||||
mainNav: IMainNav
|
||||
subNav: ISubNav
|
||||
}
|
||||
|
||||
export type IRoomInfo = {
|
||||
roomId: string | number
|
||||
roomName: string
|
||||
createTime?: string
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'normalize.css'
|
||||
import 'vue-global-api'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
import i18n from '@/i18n'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
|
||||
app.use(i18n)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,126 @@
|
||||
// import router from '@/plugins/router'
|
||||
import axios from 'axios'
|
||||
import { ElLoading, ElMessage } from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
interface IOptions {
|
||||
loading?: boolean
|
||||
message?: boolean
|
||||
clearValidateError?: boolean
|
||||
}
|
||||
// const storage = useStorage()
|
||||
export default class Axios {
|
||||
private instance
|
||||
private loading: any
|
||||
private options: IOptions = { loading: true, message: true, clearValidateError: true }
|
||||
constructor(config: any) {
|
||||
this.instance = axios.create(config)
|
||||
this.interceptors()
|
||||
}
|
||||
|
||||
public async request<T>(config: any, options?: IOptions) {
|
||||
this.options = Object.assign(this.options, options ?? {})
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const response = await this.instance.request<T>(config)
|
||||
resolve(response.data)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}) as Promise<T>
|
||||
}
|
||||
|
||||
private interceptors() {
|
||||
this.interceptorsRequest()
|
||||
this.interceptorsResponse()
|
||||
}
|
||||
|
||||
private interceptorsRequest() {
|
||||
this.instance.interceptors.request.use(
|
||||
(config: any) => {
|
||||
// if (!this.loading && this.options.loading) {
|
||||
// this.loading = ElLoading.service({
|
||||
// background: 'rgba(255,255,255,0.1)',
|
||||
// fullscreen: true
|
||||
// })
|
||||
// }
|
||||
// if (this.options.clearValidateError) useErrorStore().resetError()
|
||||
// config.headers.Accept = 'application/json'
|
||||
// config.headers.Authorization = `Bearer ${storage.get(CacheEnum.TOKEN_NAME)}`
|
||||
|
||||
return config
|
||||
},
|
||||
(error: any) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private interceptorsResponse() {
|
||||
this.instance.interceptors.response.use(
|
||||
(response) => {
|
||||
if (response.data.code !== 200) {
|
||||
ElMessage({
|
||||
type: 'error',
|
||||
message: response.data.message
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
},
|
||||
|
||||
(error) => {
|
||||
if (error.code === 'ERR_BAD_REQUEST') {
|
||||
// const [subDomain] = window.location.host.split('.')
|
||||
|
||||
const { protocol, pathname, host } = window.location
|
||||
const targetUrl =
|
||||
protocol +
|
||||
`${pathname}${pathname}` +
|
||||
(host.includes('jf') ? 'jf' : '') +
|
||||
'login.aidigitalfield.com'
|
||||
|
||||
if (error.response.status === 401) {
|
||||
if (import.meta.env.MODE != 'development') {
|
||||
window.location.href = targetUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
// if (this.loading) {
|
||||
// this.loading.close()
|
||||
// this.loading = undefined
|
||||
// }
|
||||
// this.options = { loading: true, message: true, clearValidateError: true }
|
||||
// const {
|
||||
// response: { status, data }
|
||||
// } = error
|
||||
// const message = data.error ?? data.message
|
||||
|
||||
// switch (status) {
|
||||
// case HttpCodeEnum.UNAUTHORIZED:
|
||||
// storage.remove(CacheEnum.TOKEN_NAME)
|
||||
// router.push({ name: RouteEnum.LOGIN })
|
||||
// break
|
||||
// case HttpCodeEnum.UNPROCESSABLE_ENTITY:
|
||||
// useErrorStore().setErrors(error.response.data.errors ?? error.response.data)
|
||||
// break
|
||||
// case HttpCodeEnum.FORBIDDEN:
|
||||
// ElMessage({ type: 'error', message: message ?? '没有操作权限' })
|
||||
// break
|
||||
// case HttpCodeEnum.NOT_FOUND:
|
||||
// ElMessage.error('请求资源不存在')
|
||||
// router.push({ name: RouteEnum.HOME })
|
||||
// break
|
||||
// case HttpCodeEnum.TOO_MANY_REQUESTS:
|
||||
// ElMessage({ type: 'error', message: '请求过于频繁,请稍候再试' })
|
||||
// break
|
||||
// default:
|
||||
// if (message) {
|
||||
// ElMessage({ type: 'error', message: message ?? '服务器错误' })
|
||||
// }
|
||||
// }
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useCookies } from '@vueuse/integrations/useCookies'
|
||||
import Axios from './Axios'
|
||||
import { type IClothType, type iGenderOptions, type IClotheInfo } from '@/interface'
|
||||
|
||||
const cookies = useCookies(['locale'])
|
||||
|
||||
const token = cookies.get('token')
|
||||
|
||||
const http = new Axios({
|
||||
baseURL: '',
|
||||
timeout: 300 * 1000,
|
||||
headers: {
|
||||
'X-Access-Token': token
|
||||
}
|
||||
})
|
||||
|
||||
const setup = () => {}
|
||||
|
||||
type IGetClothes = {
|
||||
sex: 'male' | 'female'
|
||||
clothType?: IClothType
|
||||
}
|
||||
|
||||
type IResponse = {
|
||||
result: any
|
||||
code: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export const getClothes = (data: { isPerson: 'yes' | 'no' } & IGetClothes) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/list',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const getAllClothes = (data: { sex: 'male' | 'female' }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/list',
|
||||
method: 'post',
|
||||
data: { isPerson: 'no', ...data }
|
||||
})
|
||||
|
||||
export const getMyAllClothes = (data: any) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/list',
|
||||
method: 'post',
|
||||
data: { isPerson: 'no', ...data }
|
||||
})
|
||||
|
||||
export const grabPicture = (data: any) =>
|
||||
http.request<
|
||||
Omit<IResponse, 'result'> & { result: Record<'id' | 'fileUrl' | 'thumbnailFileUrl', string>[] }
|
||||
>({
|
||||
url: '/api/cloth/grabPicture',
|
||||
method: 'post',
|
||||
data: { isPerson: 'no', ...data }
|
||||
})
|
||||
|
||||
export const uploadClothFile = (data: IGetClothes) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/uploadClothFile',
|
||||
method: 'post',
|
||||
data: { isPerson: 'yes', ...data }
|
||||
})
|
||||
|
||||
type IGetSuitClothes = { isPerson: 'yes' | 'no'; sex: 'female' | 'male' }
|
||||
|
||||
export const getSuitClothes = (data: IGetSuitClothes) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/list',
|
||||
method: 'post',
|
||||
data: { clothType: 'suit', ...data }
|
||||
})
|
||||
|
||||
/**加入我的衣橱 */
|
||||
export const addToMyClothes = (data: { clothType: IClothType; clothId: string }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/addWardrobe',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const unJoinFromMyClothes = (data: { clothId: string }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/unJoin',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const getModelList = (data: { sex: iGenderOptions }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/getModelList',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const createSuit = (data: { topClothId: string; bottomClothId: string }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/addSuit',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const tryOn = (data: {
|
||||
tryOnList: { clothIds: string[]; modelId: string }[]
|
||||
isHr: boolean
|
||||
}) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/tryOn',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const deleteCloth = (data: { id: string }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/deleteCloth',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const deleteModel = (data: { id: string }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/deleteCloth',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取历史批量生成数据
|
||||
*/
|
||||
export const getTryOnHistory = () =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/getTryOnHistory',
|
||||
method: 'post'
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
export const getUserInfo = () =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/getLoginUserInfo',
|
||||
method: 'post'
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取最近的一次生成试穿状态
|
||||
*/
|
||||
export const getCostTime = () =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/getCostTime',
|
||||
method: 'post'
|
||||
})
|
||||
|
||||
export const uploadModel = (formData: FormData) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/uploadModel',
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
|
||||
data: formData
|
||||
})
|
||||
|
||||
export const uploadAvatar = (formData: FormData) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/uploadAvatar',
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
|
||||
data: formData
|
||||
})
|
||||
|
||||
export const addClothes = (data: {
|
||||
clothFileList: string[]
|
||||
clothType: 'topCloth' | 'bottomCloth' | 'dress'
|
||||
isSync: 'yes' | 'no'
|
||||
sex: iGenderOptions
|
||||
}) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/uploadCloth',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const deleteHistory = (data: { historyIds: string[] }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/batchDelHistory',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
//获取试衣间列表
|
||||
export const uploadLink = (data: { buyLink: string; clothId: string }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/buyLink',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const getRoomList = (data: { sex: iGenderOptions }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/getRoomList',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const addRoom = (data: { roomName: string; sex: iGenderOptions }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/addRoom',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const deleteRoom = (data: { roomId: string | number }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/delRoom',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const updateRoom = (data: { roomId: string | number; roomName: string }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/updateRoom',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const getRoomClothList = (data: { roomId: string | number; sex: iGenderOptions }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/getRoomClothList',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const addRoomCloth = (data: { roomId: string | number; clothId: IClotheInfo['clothId'] }) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/addRoomCloth',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const deleteRoomCloth = (data: {
|
||||
roomId: string | number
|
||||
clothId: IClotheInfo['clothId']
|
||||
}) =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/cloth/delRoomCloth',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const loginOut = () =>
|
||||
http.request<IResponse>({
|
||||
url: '/api/digital/logout',
|
||||
method: 'post'
|
||||
})
|
||||
|
||||
export { http, setup }
|
||||
|
||||
export default {
|
||||
getAllClothes,
|
||||
getMyAllClothes,
|
||||
getSuitClothes,
|
||||
addToMyClothes,
|
||||
unJoinFromMyClothes,
|
||||
getModelList,
|
||||
createSuit,
|
||||
tryOn,
|
||||
getTryOnHistory,
|
||||
getUserInfo,
|
||||
uploadModel,
|
||||
uploadAvatar,
|
||||
getClothes,
|
||||
deleteCloth,
|
||||
grabPicture,
|
||||
deleteModel,
|
||||
addClothes,
|
||||
getCostTime,
|
||||
deleteHistory,
|
||||
uploadLink,
|
||||
getRoomList,
|
||||
addRoom,
|
||||
deleteRoom,
|
||||
updateRoom,
|
||||
getRoomClothList,
|
||||
addRoomCloth,
|
||||
deleteRoomCloth,
|
||||
loginOut
|
||||
}
|
||||