feat: 统一后台与用户端图片与文件上传交互,支持点击放大预览、删除与多图批量多选上传

这个提交包含在:
wikiboss
2026-09-21 01:28:01 +08:00
父节点 56392d8f9e
当前提交 955c73e7ef
共有 47 个文件被更改,包括 2605 次插入 和 77 次删除

查看文件

@@ -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;