feat: 统一后台与用户端图片与文件上传交互,支持点击放大预览、删除与多图批量多选上传
这个提交包含在:
@@ -18,6 +18,42 @@
|
||||
<view class="card-form">
|
||||
<text class="card-heading">{{ t('heading') }}</text>
|
||||
|
||||
<!-- Card avatar upload and preview -->
|
||||
<view class="avatar-field">
|
||||
<view class="avatar-wrap">
|
||||
<image
|
||||
v-if="state.avatarUrl !== ''"
|
||||
class="avatar-img"
|
||||
:src="state.avatarUrl"
|
||||
mode="aspectFill"
|
||||
@click="handleAvatarPreview"
|
||||
/>
|
||||
<view
|
||||
v-else
|
||||
class="avatar-placeholder"
|
||||
@click="handleAvatarChoose"
|
||||
>
|
||||
<text class="avatar-placeholder-text">{{ t('avatarPlaceholder') }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="state.avatarUrl !== ''"
|
||||
class="avatar-delete-badge"
|
||||
@click="handleAvatarDelete"
|
||||
>
|
||||
<text class="avatar-delete-icon">×</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="avatar-actions">
|
||||
<button
|
||||
class="avatar-action-btn"
|
||||
:disabled="state.saving"
|
||||
@click="handleAvatarChoose"
|
||||
>
|
||||
<text class="avatar-action-btn-text">{{ avatarActionText }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">{{ t('name') }}</text>
|
||||
<input
|
||||
@@ -165,10 +201,42 @@ const saveLabel = computed((): string => {
|
||||
return resolveCardEditText('save');
|
||||
});
|
||||
|
||||
const avatarActionText = computed((): string => {
|
||||
return state.avatarUrl !== '' ? resolveCardEditText('avatarChange') : resolveCardEditText('avatarUpload');
|
||||
});
|
||||
|
||||
function t(key: CardEditCopyKey): string {
|
||||
return resolveCardEditText(key);
|
||||
}
|
||||
|
||||
function handleAvatarChoose(): void {
|
||||
if (state.saving) {
|
||||
return;
|
||||
}
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res: { tempFilePaths: string[] }) => {
|
||||
if (res.tempFilePaths && res.tempFilePaths.length > 0) {
|
||||
state.avatarUrl = res.tempFilePaths[0];
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleAvatarPreview(): void {
|
||||
if (state.avatarUrl !== '') {
|
||||
uni.previewImage({
|
||||
urls: [state.avatarUrl],
|
||||
current: state.avatarUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleAvatarDelete(): void {
|
||||
state.avatarUrl = '';
|
||||
}
|
||||
|
||||
function handleBack(): void {
|
||||
goBackFromCardEdit();
|
||||
}
|
||||
@@ -192,6 +260,84 @@ onLoad((options: CardEditPageOptions) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.avatar-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: var(--oc-space-16, 16px);
|
||||
padding: var(--oc-space-12, 12px) 0;
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 40px;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--oc-primary, #0B5CFF);
|
||||
background-color: var(--oc-surface, #F7F8FA);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-placeholder-text {
|
||||
font-size: 12px;
|
||||
color: var(--oc-text-2, #667085);
|
||||
}
|
||||
|
||||
.avatar-delete-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background-color: rgba(240, 68, 56, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-delete-icon {
|
||||
color: #FFFFFF;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.avatar-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.avatar-action-btn {
|
||||
padding: 4px 12px;
|
||||
background-color: var(--oc-primary-soft, #EAF1FF);
|
||||
border-radius: var(--oc-radius-btn, 12px);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.avatar-action-btn-text {
|
||||
font-size: 12px;
|
||||
color: var(--oc-primary, #0B5CFF);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-edit-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -22,7 +22,10 @@ export type CardEditCopyKey =
|
||||
| 'error'
|
||||
| 'empty'
|
||||
| 'missingId'
|
||||
| 'back';
|
||||
| 'back'
|
||||
| 'avatarUpload'
|
||||
| 'avatarChange'
|
||||
| 'avatarPlaceholder';
|
||||
|
||||
export type CardEditForm = {
|
||||
id?: string | number | null;
|
||||
@@ -30,6 +33,7 @@ export type CardEditForm = {
|
||||
title?: string;
|
||||
company?: string;
|
||||
slogan?: string;
|
||||
avatarUrl?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
whatsapp?: string;
|
||||
@@ -45,6 +49,7 @@ export type CardUpdatePayload = {
|
||||
title?: string;
|
||||
company?: string;
|
||||
slogan?: string;
|
||||
avatarUrl?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
whatsapp?: string;
|
||||
@@ -86,6 +91,7 @@ export type CardEditState = {
|
||||
name: string;
|
||||
title: string;
|
||||
company: string;
|
||||
avatarUrl: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
whatsapp: string;
|
||||
@@ -153,6 +159,15 @@ export function resolveCardEditText(key: CardEditCopyKey): string {
|
||||
if (key === 'back') {
|
||||
return zh ? '返回名片' : 'Back to card';
|
||||
}
|
||||
if (key === 'avatarUpload') {
|
||||
return zh ? '点击拍照/上传' : 'Upload photo';
|
||||
}
|
||||
if (key === 'avatarChange') {
|
||||
return zh ? '更换头像' : 'Change avatar';
|
||||
}
|
||||
if (key === 'avatarPlaceholder') {
|
||||
return zh ? '上传头像' : 'Upload avatar';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -202,6 +217,7 @@ function assignFilled(
|
||||
| 'title'
|
||||
| 'company'
|
||||
| 'slogan'
|
||||
| 'avatarUrl'
|
||||
| 'phone'
|
||||
| 'email'
|
||||
| 'whatsapp'
|
||||
@@ -230,6 +246,7 @@ export function buildUpdatePayload(form: CardEditForm): CardUpdatePayload | null
|
||||
assignFilled(payload, 'title', form.title);
|
||||
assignFilled(payload, 'company', form.company);
|
||||
assignFilled(payload, 'slogan', form.slogan);
|
||||
assignFilled(payload, 'avatarUrl', form.avatarUrl);
|
||||
assignFilled(payload, 'phone', form.phone);
|
||||
assignFilled(payload, 'email', form.email);
|
||||
assignFilled(payload, 'whatsapp', form.whatsapp);
|
||||
@@ -334,6 +351,7 @@ export function createCardEditState(): CardEditState {
|
||||
name: '',
|
||||
title: '',
|
||||
company: '',
|
||||
avatarUrl: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
whatsapp: '',
|
||||
@@ -382,6 +400,7 @@ function applyCardToState(state: CardEditState, card: CardProfile): void {
|
||||
state.name = card.name != null ? String(card.name) : '';
|
||||
state.title = readOptionalText(card.title);
|
||||
state.company = readOptionalText(card.company);
|
||||
state.avatarUrl = readOptionalText(card.avatarUrl);
|
||||
state.phone = readOptionalText(card.phone);
|
||||
state.email = readOptionalText(card.email);
|
||||
state.whatsapp = readOptionalText(card.whatsapp);
|
||||
@@ -408,6 +427,7 @@ export function formFromState(state: CardEditState): CardEditForm {
|
||||
name: state.name,
|
||||
title: state.title,
|
||||
company: state.company,
|
||||
avatarUrl: state.avatarUrl,
|
||||
phone: state.phone,
|
||||
email: state.email,
|
||||
whatsapp: state.whatsapp,
|
||||
|
||||
@@ -53,6 +53,14 @@
|
||||
:placeholder="notesPlaceholder"
|
||||
:disabled="state.saving"
|
||||
/>
|
||||
<view class="notes-attachments">
|
||||
<image-uploader
|
||||
v-model="state.attachments"
|
||||
:multiple="true"
|
||||
:maxCount="6"
|
||||
:disabled="state.saving"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="state.errorMessage !== ''" class="lead-error">
|
||||
@@ -90,6 +98,7 @@ import {
|
||||
LeadDetailCopyKey,
|
||||
LeadDetailPageOptions,
|
||||
} from './useLeadDetail.uts';
|
||||
import ImageUploader from '../../components/image-uploader/image-uploader.uvue';
|
||||
|
||||
const pageTheme: Record<string, string> = {
|
||||
'--oc-primary': utsLightColors.primary,
|
||||
@@ -366,6 +375,10 @@ onLoad((options: LeadDetailPageOptions) => {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.notes-attachments {
|
||||
margin-top: var(--oc-space-12, 12px);
|
||||
}
|
||||
|
||||
.lead-error {
|
||||
margin-bottom: var(--oc-space-12, 12px);
|
||||
}
|
||||
|
||||
@@ -13,11 +13,29 @@
|
||||
<text class="section-title">{{ t('accountSection') }}</text>
|
||||
|
||||
<view class="account-header">
|
||||
<image
|
||||
class="account-avatar"
|
||||
:src="state.account.avatar"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="avatar-container">
|
||||
<image
|
||||
v-if="state.account.avatar !== ''"
|
||||
class="account-avatar"
|
||||
:src="state.account.avatar"
|
||||
mode="aspectFill"
|
||||
@click="onAvatarClick"
|
||||
/>
|
||||
<view
|
||||
v-else
|
||||
class="account-avatar-placeholder"
|
||||
@click="onAvatarChoose"
|
||||
>
|
||||
<text class="avatar-placeholder-plus">+</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="state.account.avatar !== ''"
|
||||
class="avatar-del-badge"
|
||||
@click="onAvatarDelete"
|
||||
>
|
||||
<text class="avatar-del-icon">×</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="account-info">
|
||||
<text class="account-name">{{ state.account.name }}</text>
|
||||
<text class="account-meta">{{ state.account.company }}</text>
|
||||
@@ -117,6 +135,8 @@ import {
|
||||
toggleLeadNotice,
|
||||
setPreferredLocale,
|
||||
resolveProfileText,
|
||||
previewAccountAvatar,
|
||||
removeAccountAvatar,
|
||||
} from './useProfile.uts';
|
||||
import { OmniLocale } from '../../../common/locale.uts';
|
||||
|
||||
@@ -127,6 +147,30 @@ type LangOption = {
|
||||
|
||||
const state = reactive<ProfileState>(loadProfileSettings());
|
||||
|
||||
function onAvatarClick(): void {
|
||||
if (state.account.avatar !== '') {
|
||||
previewAccountAvatar(state);
|
||||
} else {
|
||||
onAvatarChoose();
|
||||
}
|
||||
}
|
||||
|
||||
function onAvatarChoose(): void {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res: { tempFilePaths: string[] }) => {
|
||||
if (res.tempFilePaths && res.tempFilePaths.length > 0) {
|
||||
state.account.avatar = res.tempFilePaths[0];
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onAvatarDelete(): void {
|
||||
removeAccountAvatar(state);
|
||||
}
|
||||
|
||||
const languageList: LangOption[] = [
|
||||
{ code: 'zh-CN', label: '简体中文' },
|
||||
{ code: 'en-US', label: 'English' },
|
||||
@@ -230,12 +274,55 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 28px;
|
||||
background-color: #E3E7ED;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.account-avatar-placeholder {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 28px;
|
||||
border: 1px dashed #C9CDD4;
|
||||
background-color: #F7F8FA;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-placeholder-plus {
|
||||
font-size: 24px;
|
||||
color: #667085;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.avatar-del-badge {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background-color: rgba(240, 68, 56, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-del-icon {
|
||||
color: #FFFFFF;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.account-info {
|
||||
|
||||
@@ -310,6 +310,31 @@ export function setPreferredLocale(
|
||||
return targetLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览个人头像
|
||||
*/
|
||||
export function previewAccountAvatar(state: ProfileState): void {
|
||||
if (state.account.avatar !== '') {
|
||||
// #ifndef UNI-APP-X-TEST
|
||||
try {
|
||||
if (typeof uni !== 'undefined' && typeof uni.previewImage === 'function') {
|
||||
uni.previewImage({
|
||||
urls: [state.account.avatar],
|
||||
current: state.account.avatar,
|
||||
});
|
||||
}
|
||||
} catch (_e) {}
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除个人头像
|
||||
*/
|
||||
export function removeAccountAvatar(state: ProfileState): void {
|
||||
state.account.avatar = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 瑞士国际主义多语言文案解析器,无 emoji
|
||||
*/
|
||||
|
||||
@@ -80,6 +80,7 @@ export type LeadDetailState = {
|
||||
intentScore: number;
|
||||
existingNotes: string;
|
||||
nextNote: string;
|
||||
attachments: string[];
|
||||
tracks: RadarTrack[];
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
@@ -312,6 +313,7 @@ export function createLeadDetailState(): LeadDetailState {
|
||||
intentScore: 0,
|
||||
existingNotes: '',
|
||||
nextNote: '',
|
||||
attachments: [],
|
||||
tracks: [],
|
||||
loading: false,
|
||||
saving: false,
|
||||
|
||||
@@ -26,6 +26,20 @@
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 拍摄的名片图片卡片:可点击放大预览、可删除 -->
|
||||
<view v-if="ocrState.imagePath !== ''" class="ocr-preview-card">
|
||||
<view class="ocr-preview-thumb-wrap" @click="handlePreviewOcrImage">
|
||||
<image class="ocr-preview-img" :src="ocrState.imagePath" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="ocr-preview-info" @click="handlePreviewOcrImage">
|
||||
<text class="ocr-preview-title">名片原图</text>
|
||||
<text class="ocr-preview-hint">点击放大预览原图</text>
|
||||
</view>
|
||||
<view class="ocr-preview-delete" @click="handleRemoveOcrImage">
|
||||
<text class="ocr-preview-delete-text">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="field-label">姓名</text>
|
||||
<input
|
||||
@@ -105,6 +119,8 @@ import {
|
||||
import {
|
||||
createOcrScanState,
|
||||
handleCardOcrUpload,
|
||||
previewOcrImage,
|
||||
removeOcrImage,
|
||||
} from './useOcrExchange.uts';
|
||||
|
||||
const pageTheme: Record<string, string> = {
|
||||
@@ -125,6 +141,14 @@ const pageTheme: Record<string, string> = {
|
||||
const state = reactive(createExchangeState());
|
||||
const ocrState = reactive(createOcrScanState());
|
||||
|
||||
function handlePreviewOcrImage(): void {
|
||||
previewOcrImage(ocrState);
|
||||
}
|
||||
|
||||
function handleRemoveOcrImage(): void {
|
||||
removeOcrImage(ocrState);
|
||||
}
|
||||
|
||||
async function handleOcrScan(): Promise<void> {
|
||||
if (ocrState.scanning) {
|
||||
return;
|
||||
@@ -142,6 +166,57 @@ onLoad((options: ExchangePageOptions) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ocr-preview-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--oc-surface, #F7F8FA);
|
||||
border: 1px solid var(--oc-border, #E3E7ED);
|
||||
border-radius: var(--oc-radius-btn, 12px);
|
||||
margin-bottom: var(--oc-space-16, 16px);
|
||||
}
|
||||
|
||||
.ocr-preview-thumb-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.ocr-preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ocr-preview-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ocr-preview-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--oc-text, #101828);
|
||||
}
|
||||
|
||||
.ocr-preview-hint {
|
||||
font-size: 11px;
|
||||
color: var(--oc-primary, #0B5CFF);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ocr-preview-delete {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.ocr-preview-delete-text {
|
||||
font-size: 12px;
|
||||
color: var(--oc-text-2, #667085);
|
||||
}
|
||||
|
||||
.exchange-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -18,6 +18,7 @@ export type ExtractedContactInfo = {
|
||||
export type OcrScanState = {
|
||||
scanning: boolean;
|
||||
rawText: string;
|
||||
imagePath: string;
|
||||
lastExtracted: ExtractedContactInfo | null;
|
||||
errorMessage: string;
|
||||
};
|
||||
@@ -76,11 +77,37 @@ export function createOcrScanState(): OcrScanState {
|
||||
return {
|
||||
scanning: false,
|
||||
rawText: '',
|
||||
imagePath: '',
|
||||
lastExtracted: null,
|
||||
errorMessage: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览已拍照的名片图片
|
||||
*/
|
||||
export function previewOcrImage(ocrState: OcrScanState): void {
|
||||
if (ocrState.imagePath !== '') {
|
||||
// #ifndef UNI-APP-X-TEST
|
||||
try {
|
||||
if (typeof uni !== 'undefined' && typeof uni.previewImage === 'function') {
|
||||
uni.previewImage({
|
||||
urls: [ocrState.imagePath],
|
||||
current: ocrState.imagePath,
|
||||
});
|
||||
}
|
||||
} catch (_e) {}
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除已拍摄的名片图片
|
||||
*/
|
||||
export function removeOcrImage(ocrState: OcrScanState): void {
|
||||
ocrState.imagePath = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为职位关键词
|
||||
*/
|
||||
@@ -571,6 +598,15 @@ export async function handleCardOcrUpload(
|
||||
count: 1,
|
||||
sourceType: sourceTypes,
|
||||
success: (_res: unknown) => {
|
||||
let path = '';
|
||||
if (_res != null && typeof _res === 'object') {
|
||||
const r = _res as Record<string, unknown>;
|
||||
if (Array.isArray(r.tempFilePaths) && r.tempFilePaths.length > 0) {
|
||||
path = String(r.tempFilePaths[0]);
|
||||
}
|
||||
}
|
||||
ocrState.imagePath = path !== '' ? path : 'https://cdn.omnicard.ai/mock-card.jpg';
|
||||
|
||||
// 图像上传与 OCR 解析
|
||||
const sample = simulateOcrScan('cn');
|
||||
ocrState.rawText = sample.rawText;
|
||||
|
||||
在新工单中引用
屏蔽一个用户