feat: 统一后台与用户端图片与文件上传交互,支持点击放大预览、删除与多图批量多选上传
这个提交包含在:
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<view class="uploader-container">
|
||||
<view class="uploader-grid">
|
||||
<!-- 已选图片列表 -->
|
||||
<view
|
||||
v-for="(url, index) in state.images"
|
||||
:key="index"
|
||||
class="uploader-item"
|
||||
>
|
||||
<image
|
||||
class="uploader-img"
|
||||
:src="url"
|
||||
mode="aspectFill"
|
||||
@click="handlePreview(index)"
|
||||
/>
|
||||
<view
|
||||
v-if="!disabled"
|
||||
class="uploader-delete-btn"
|
||||
@click="handleDelete(index)"
|
||||
>
|
||||
<text class="uploader-delete-icon">×</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 上传卡片触发区 -->
|
||||
<view
|
||||
v-if="canAdd && !disabled"
|
||||
class="uploader-trigger"
|
||||
@click="handleChoose"
|
||||
>
|
||||
<text class="uploader-trigger-plus">+</text>
|
||||
<text class="uploader-trigger-text">{{ state.uploading ? '上传中' : '添加图片' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import {
|
||||
createImageUploaderState,
|
||||
removeImageByIndex,
|
||||
previewImageAt,
|
||||
chooseAndUploadImages,
|
||||
ImageUploaderState,
|
||||
} from './useImageUploader.uts';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string[];
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => [] as string[],
|
||||
multiple: false,
|
||||
maxCount: 9,
|
||||
disabled: false,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string[]): void;
|
||||
(e: 'change', value: string[]): void;
|
||||
}>();
|
||||
|
||||
const state = reactive(createImageUploaderState(props.modelValue));
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal: string[]) => {
|
||||
state.images = newVal != null ? newVal.slice() : [];
|
||||
}
|
||||
);
|
||||
|
||||
const canAdd = computed(() => {
|
||||
const limit = props.multiple ? props.maxCount : 1;
|
||||
return state.images.length < limit;
|
||||
});
|
||||
|
||||
function handlePreview(index: number): void {
|
||||
previewImageAt(state, index);
|
||||
}
|
||||
|
||||
function handleDelete(index: number): void {
|
||||
removeImageByIndex(state, index);
|
||||
emit('update:modelValue', state.images);
|
||||
emit('change', state.images);
|
||||
}
|
||||
|
||||
async function handleChoose(): Promise<void> {
|
||||
if (state.uploading || props.disabled) {
|
||||
return;
|
||||
}
|
||||
const ok = await chooseAndUploadImages(state, {
|
||||
multiple: props.multiple,
|
||||
maxCount: props.multiple ? props.maxCount : 1,
|
||||
});
|
||||
if (ok) {
|
||||
emit('update:modelValue', state.images);
|
||||
emit('change', state.images);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.uploader-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.uploader-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.uploader-item {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--oc-border, #E3E7ED);
|
||||
background-color: var(--oc-surface, #F7F8FA);
|
||||
}
|
||||
|
||||
.uploader-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.uploader-delete-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.uploader-delete-icon {
|
||||
color: #FFFFFF;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.uploader-trigger {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--oc-border, #C9CDD4);
|
||||
background-color: var(--oc-surface, #F7F8FA);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.uploader-trigger-plus {
|
||||
font-size: 24px;
|
||||
color: var(--oc-text-2, #667085);
|
||||
line-height: 1;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.uploader-trigger-text {
|
||||
font-size: 11px;
|
||||
color: var(--oc-text-2, #667085);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - 跨端统一图片上传核心业务驱动层 (UTS)
|
||||
* 严格遵循 uts 强类型、零 emoji 与 AGENTS.md 约束
|
||||
* 支持单图/多图上传、大图预览 (uni.previewImage)、独立删除与上限拦截
|
||||
*/
|
||||
|
||||
export type ImageUploaderState = {
|
||||
images: string[];
|
||||
uploading: boolean;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export type ChooseImageCallbackRes = {
|
||||
tempFilePaths: string[];
|
||||
};
|
||||
|
||||
export type ChooseImageOptions = {
|
||||
count?: number;
|
||||
sizeType?: string[];
|
||||
sourceType?: string[];
|
||||
success?: (res: ChooseImageCallbackRes) => void;
|
||||
fail?: (err: unknown) => void;
|
||||
};
|
||||
|
||||
export type ImageUploaderOptions = {
|
||||
multiple?: boolean;
|
||||
maxCount?: number;
|
||||
sourceType?: string[];
|
||||
chooseImageFn?: (options: ChooseImageOptions) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建上传状态
|
||||
*/
|
||||
export function createImageUploaderState(initialImages?: string[]): ImageUploaderState {
|
||||
return {
|
||||
images: initialImages != null ? initialImages.slice() : [],
|
||||
uploading: false,
|
||||
errorMessage: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加图片 URL,并严格按 maxCount 截断
|
||||
*/
|
||||
export function addImageUrls(
|
||||
state: ImageUploaderState,
|
||||
urls: string[],
|
||||
maxCount: number = 9
|
||||
): void {
|
||||
const nextList: string[] = [];
|
||||
for (let i = 0; i < state.images.length; i++) {
|
||||
nextList.push(state.images[i]);
|
||||
}
|
||||
for (let j = 0; j < urls.length; j++) {
|
||||
const url = urls[j].trim();
|
||||
if (url !== '') {
|
||||
nextList.push(url);
|
||||
}
|
||||
}
|
||||
state.images = nextList.slice(0, maxCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 依据索引删除单张图片
|
||||
*/
|
||||
export function removeImageByIndex(
|
||||
state: ImageUploaderState,
|
||||
index: number
|
||||
): void {
|
||||
if (index < 0 || index >= state.images.length) {
|
||||
return;
|
||||
}
|
||||
const nextList: string[] = [];
|
||||
for (let i = 0; i < state.images.length; i++) {
|
||||
if (i !== index) {
|
||||
nextList.push(state.images[i]);
|
||||
}
|
||||
}
|
||||
state.images = nextList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有图片
|
||||
*/
|
||||
export function clearAllImages(state: ImageUploaderState): void {
|
||||
state.images = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用于全屏大图预览的 URL 数组
|
||||
*/
|
||||
export function resolvePreviewUrls(state: ImageUploaderState): string[] {
|
||||
return state.images.slice();
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发调用系统原生全屏大图放大预览
|
||||
*/
|
||||
export function previewImageAt(
|
||||
state: ImageUploaderState,
|
||||
currentUrlOrIndex: string | number
|
||||
): void {
|
||||
const urls = resolvePreviewUrls(state);
|
||||
if (urls.length === 0) {
|
||||
return;
|
||||
}
|
||||
let current = '';
|
||||
if (typeof currentUrlOrIndex === 'number') {
|
||||
if (currentUrlOrIndex >= 0 && currentUrlOrIndex < urls.length) {
|
||||
current = urls[currentUrlOrIndex];
|
||||
} else {
|
||||
current = urls[0];
|
||||
}
|
||||
} else {
|
||||
current = currentUrlOrIndex;
|
||||
}
|
||||
|
||||
// #ifndef UNI-APP-X-TEST
|
||||
try {
|
||||
if (typeof uni !== 'undefined' && typeof uni.previewImage === 'function') {
|
||||
uni.previewImage({
|
||||
urls,
|
||||
current,
|
||||
});
|
||||
}
|
||||
} catch (_e) {}
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择图片并执行上传流程
|
||||
*/
|
||||
export async function chooseAndUploadImages(
|
||||
state: ImageUploaderState,
|
||||
options?: ImageUploaderOptions
|
||||
): Promise<boolean> {
|
||||
const opt = options != null ? options : {};
|
||||
const multiple = opt.multiple !== undefined ? opt.multiple : false;
|
||||
const maxCount = opt.maxCount !== undefined ? opt.maxCount : (multiple ? 9 : 1);
|
||||
const remainingCount = maxCount - state.images.length;
|
||||
|
||||
if (remainingCount <= 0) {
|
||||
state.errorMessage = '已达到上传上限';
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectCount = multiple ? remainingCount : 1;
|
||||
const sourceTypes = opt.sourceType != null && opt.sourceType.length > 0
|
||||
? opt.sourceType
|
||||
: ['album', 'camera'];
|
||||
|
||||
state.uploading = true;
|
||||
state.errorMessage = '';
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const chooseFn = opt.chooseImageFn != null
|
||||
? opt.chooseImageFn
|
||||
: (typeof uni !== 'undefined' && typeof uni.chooseImage === 'function' ? uni.chooseImage : null);
|
||||
|
||||
if (!chooseFn) {
|
||||
state.uploading = false;
|
||||
state.errorMessage = 'chooseImage 不可用';
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
chooseFn({
|
||||
count: selectCount,
|
||||
sourceType: sourceTypes,
|
||||
success: (res: ChooseImageCallbackRes) => {
|
||||
if (res.tempFilePaths != null && res.tempFilePaths.length > 0) {
|
||||
addImageUrls(state, res.tempFilePaths, maxCount);
|
||||
state.uploading = false;
|
||||
resolve(true);
|
||||
} else {
|
||||
state.uploading = false;
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
fail: (err: unknown) => {
|
||||
state.uploading = false;
|
||||
let msg = '取消选择图片';
|
||||
if (err instanceof Error) {
|
||||
msg = err.message;
|
||||
}
|
||||
state.errorMessage = msg;
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
state.uploading = false;
|
||||
state.errorMessage = e instanceof Error ? e.message : '选择异常';
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
在新工单中引用
屏蔽一个用户