文件
OmniCard/mpuniapp/pages/owner/card/edit.uvue
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

1422 行
41 KiB
Plaintext

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<view class="card-edit-page" :class="pageRtlClass" :style="pageTheme">
<!-- Empty / Missing ID State -->
<view v-if="state.missingId || state.empty" class="card-empty">
<text class="card-empty-text">{{ emptyMessage }}</text>
<button class="primary-cta-btn" @click="handleBack">
<text class="primary-cta-btn-text">{{ t('back') }}</text>
</button>
</view>
<!-- Skeleton Loading State -->
<view v-else-if="state.loading" class="card-skeleton">
<view class="skeleton-header">
<view class="skeleton-line skeleton-title-line"></view>
<view class="skeleton-line skeleton-sub-line"></view>
</view>
<view class="skeleton-card skeleton-card-lg"></view>
<view class="skeleton-card skeleton-card-md"></view>
<view class="skeleton-card skeleton-card-sm"></view>
</view>
<!-- Main Edit Content -->
<view v-else class="card-body">
<scroll-view class="card-scroll" :scroll-y="true">
<view class="card-content">
<!-- Page Header & Hint -->
<view class="card-header-bar">
<view class="card-title-group">
<text class="card-heading">{{ t('heading') }}</text>
<text class="card-subtitle">{{ t('quickEditSub') }}</text>
</view>
<view class="builder-hint-pill">
<text class="builder-hint-pill-text">{{ t('builderHint') }}</text>
</view>
</view>
<!-- Quick Links: Cases & Articles -->
<view class="quick-entry-grid">
<view class="quick-entry-card" @click="handleGoCases">
<view class="quick-entry-top">
<text class="quick-entry-badge">PORTFOLIO</text>
<text class="quick-entry-arrow">↗</text>
</view>
<text class="quick-entry-title">{{ casesEntryTitle }}</text>
<text class="quick-entry-sub">{{ casesEntrySub }}</text>
</view>
<view class="quick-entry-card" @click="handleGoArticles">
<view class="quick-entry-top">
<text class="quick-entry-badge">INSIGHTS</text>
<text class="quick-entry-arrow">↗</text>
</view>
<text class="quick-entry-title">{{ articlesEntryTitle }}</text>
<text class="quick-entry-sub">{{ articlesEntrySub }}</text>
</view>
</view>
<view class="modules-stack">
<!-- Module 1: Basic Profile -->
<view class="module-card" :style="moduleCardStyle('basic')">
<view class="module-card-header">
<view class="module-title-wrap">
<view class="module-indicator"></view>
<text class="module-title">{{ t('basicSection') }}</text>
</view>
<view class="module-order-actions">
<button
class="module-order-btn"
:disabled="state.saving || !canMoveModule('basic', -1)"
:aria-label="t('moveUp')"
@click="handleMoveModule('basic', -1)"
>
<text class="module-order-btn-text">{{ t('moveUp') }}</text>
</button>
<button
class="module-order-btn"
:disabled="state.saving || !canMoveModule('basic', 1)"
:aria-label="t('moveDown')"
@click="handleMoveModule('basic', 1)"
>
<text class="module-order-btn-text">{{ t('moveDown') }}</text>
</button>
</view>
</view>
<!-- Avatar Uploader Stage -->
<view class="avatar-stage-box">
<view class="avatar-stage">
<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>
<view
v-if="state.avatarUrl !== ''"
class="avatar-delete-hit"
:aria-label="deleteAriaLabel"
@click="handleAvatarDelete"
>
<view class="avatar-delete-badge">
<text class="avatar-delete-icon">&#215;</text>
</view>
</view>
</view>
<button
class="avatar-action-btn"
:disabled="state.saving"
:aria-label="avatarActionText"
@click="handleAvatarChoose"
>
<text class="avatar-action-btn-text">{{ avatarActionText }}</text>
</button>
</view>
<!-- Basic Fields -->
<view class="field">
<text class="field-label">{{ t('name') }}</text>
<input
class="field-input"
type="text"
v-model="state.name"
:placeholder="t('name')"
:disabled="state.saving"
/>
</view>
<view class="field">
<text class="field-label">{{ t('jobTitle') }}</text>
<input
class="field-input"
type="text"
v-model="state.title"
:placeholder="t('jobTitle')"
:disabled="state.saving"
/>
</view>
<view class="field">
<text class="field-label">{{ t('company') }}</text>
<input
class="field-input"
:class="{ 'field-input-locked': companyLocked }"
type="text"
v-model="state.company"
:placeholder="t('company')"
:disabled="state.saving || companyLocked"
/>
<text v-if="companyLocked" class="field-lock-hint">{{ t('lockedHint') }}</text>
</view>
<view class="field">
<text class="field-label">{{ t('slogan') }}</text>
<input
class="field-input"
:class="{ 'field-input-locked': sloganLocked }"
type="text"
v-model="state.slogan"
:placeholder="t('slogan')"
:disabled="state.saving || sloganLocked"
/>
<text v-if="sloganLocked" class="field-lock-hint">{{ t('lockedHint') }}</text>
</view>
<view class="field field-last">
<text class="field-label">{{ t('timezone') }}</text>
<input
class="field-input"
type="text"
v-model="state.timezone"
:placeholder="t('timezone')"
:disabled="state.saving"
/>
</view>
</view>
<!-- Module 2: Social Matrix -->
<view class="module-card" :style="moduleCardStyle('social')">
<view class="module-card-header">
<view class="module-title-wrap">
<view class="module-indicator"></view>
<text class="module-title">{{ t('socialSection') }}</text>
</view>
<view class="module-order-actions">
<button
class="module-order-btn"
:disabled="state.saving || !canMoveModule('social', -1)"
:aria-label="t('moveUp')"
@click="handleMoveModule('social', -1)"
>
<text class="module-order-btn-text">{{ t('moveUp') }}</text>
</button>
<button
class="module-order-btn"
:disabled="state.saving || !canMoveModule('social', 1)"
:aria-label="t('moveDown')"
@click="handleMoveModule('social', 1)"
>
<text class="module-order-btn-text">{{ t('moveDown') }}</text>
</button>
</view>
</view>
<text class="module-subtitle module-subtitle-block">{{ t('socialSectionDesc') }}</text>
<view class="field">
<text class="field-label">{{ t('phone') }}</text>
<input
class="field-input"
type="text"
v-model="state.phone"
:placeholder="t('phone')"
:disabled="state.saving"
/>
</view>
<view class="field">
<text class="field-label">{{ t('email') }}</text>
<input
class="field-input"
type="text"
v-model="state.email"
:placeholder="t('email')"
:disabled="state.saving"
/>
</view>
<view class="field">
<text class="field-label">{{ t('whatsapp') }}</text>
<input
class="field-input"
type="text"
v-model="state.whatsapp"
:placeholder="t('whatsapp')"
:disabled="state.saving"
/>
</view>
<view class="field field-last">
<text class="field-label">{{ t('linkedin') }}</text>
<input
class="field-input"
type="text"
v-model="state.linkedin"
:placeholder="t('linkedin')"
:disabled="state.saving"
/>
</view>
</view>
<!-- Module 3: Privacy & Visibility -->
<view class="module-card" :style="moduleCardStyle('privacy')">
<view class="module-card-header">
<view class="module-title-wrap">
<view class="module-indicator"></view>
<text class="module-title">{{ t('privacySection') }}</text>
</view>
<view class="module-order-actions">
<button
class="module-order-btn"
:disabled="state.saving || !canMoveModule('privacy', -1)"
:aria-label="t('moveUp')"
@click="handleMoveModule('privacy', -1)"
>
<text class="module-order-btn-text">{{ t('moveUp') }}</text>
</button>
<button
class="module-order-btn"
:disabled="state.saving || !canMoveModule('privacy', 1)"
:aria-label="t('moveDown')"
@click="handleMoveModule('privacy', 1)"
>
<text class="module-order-btn-text">{{ t('moveDown') }}</text>
</button>
</view>
</view>
<view class="gdpr-pill gdpr-pill-inline">
<text class="gdpr-pill-text">{{ t('gdprBadge') }}</text>
</view>
<!-- Visibility Options -->
<view class="privacy-options-group">
<!-- Extended Visibility Option -->
<view
class="privacy-option-item"
:class="{ 'privacy-option-active': visibilityMode === 'extended' }"
@click="handleSelectVisibility('extended')"
>
<view class="privacy-option-main">
<view class="privacy-radio-circle">
<view v-if="visibilityMode === 'extended'" class="privacy-radio-inner"></view>
</view>
<view class="privacy-option-content">
<text class="privacy-option-title">{{ t('extendedVisibility') }}</text>
<text class="privacy-option-desc">{{ t('extendedVisibilityDesc') }}</text>
</view>
</view>
</view>
<!-- Temporary Visibility Option -->
<view
class="privacy-option-item"
:class="{ 'privacy-option-active': visibilityMode === 'temporary' }"
@click="handleSelectVisibility('temporary')"
>
<view class="privacy-option-main">
<view class="privacy-radio-circle">
<view v-if="visibilityMode === 'temporary'" class="privacy-radio-inner"></view>
</view>
<view class="privacy-option-content">
<text class="privacy-option-title">{{ t('temporaryVisibility') }}</text>
<text class="privacy-option-desc">{{ t('temporaryVisibilityDesc') }}</text>
</view>
</view>
<!-- Expanded duration pills when Temporary is active -->
<view v-if="visibilityMode === 'temporary'" class="privacy-duration-expand">
<text class="privacy-duration-label">{{ t('visibilityDuration') }}</text>
<view class="privacy-duration-pills">
<view
class="duration-pill"
:class="{ 'duration-pill-active': temporaryDays === 1 }"
@click.stop="handleSelectDuration(1)"
>
<text class="duration-pill-text">{{ t('duration24h') }}</text>
</view>
<view
class="duration-pill"
:class="{ 'duration-pill-active': temporaryDays === 7 }"
@click.stop="handleSelectDuration(7)"
>
<text class="duration-pill-text">{{ t('duration7d') }}</text>
</view>
<view
class="duration-pill"
:class="{ 'duration-pill-active': temporaryDays === 30 }"
@click.stop="handleSelectDuration(30)"
>
<text class="duration-pill-text">{{ t('duration30d') }}</text>
</view>
</view>
<view class="privacy-note-box">
<text class="privacy-note-text">{{ t('temporaryNote') }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- Error Alert Banner -->
<view v-if="state.errorMessage !== ''" class="card-error">
<text class="card-error-text">{{ state.errorMessage }}</text>
</view>
<view class="card-bottom-spacer"></view>
</view>
</scroll-view>
<!-- Single Primary CTA Floating Bar -->
<view class="card-cta-bar">
<button
class="primary-cta-btn"
:class="{ 'primary-cta-btn-disabled': state.saving }"
:disabled="state.saving"
:aria-label="saveLabel"
@click="handleSave"
>
<text class="primary-cta-btn-text">{{ saveLabel }}</text>
</button>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { getCurrentLocale, getOmniText, isRtl, OmniLocale } from '../../../common/locale.uts';
import { utsLightColors, utsRadius, utsSpacing, utsTypography } from '../../../common/tokens.uts';
import {
applyCardEditOptions,
createCardEditState,
goBackFromCardEdit,
refreshCardEdit,
resolveCardEditText,
submitCardEdit,
saveCard,
updateOwnerCard,
fetchOwnerCardInfo,
buildUpdatePayload,
isFieldLocked,
moveModuleInOrder,
CardEditCopyKey,
CardEditPageOptions,
} from './useCardEdit.uts';
type VisibilityMode = 'extended' | 'temporary';
type CardEditExtendedKey =
| CardEditCopyKey
| 'builderHint'
| 'quickEditSub'
| 'basicSection'
| 'socialSection'
| 'socialSectionDesc'
| 'privacySection'
| 'gdprBadge'
| 'extendedVisibility'
| 'extendedVisibilityDesc'
| 'temporaryVisibility'
| 'temporaryVisibilityDesc'
| 'visibilityDuration'
| 'duration24h'
| 'duration7d'
| 'duration30d'
| 'temporaryNote'
| 'lockedHint';
const EXTENDED_COPY: Record<OmniLocale, Record<string, string>> = {
'zh-CN': {
builderHint: '更多高级搭建请用后台搭建器',
quickEditSub: '现场即改即用 · 毫秒级多端同步',
basicSection: '基本身份档案',
socialSection: '商务社交矩阵',
socialSectionDesc: '跨国多渠道联络直达',
privacySection: '隐私与时效控制',
gdprBadge: 'GDPR 规范',
extendedVisibility: '商务常态公开 (Extended)',
extendedVisibilityDesc: '长期向客商公开名片与联络渠道,保持全渠道畅联',
temporaryVisibility: '展会限时时效 (Temporary)',
temporaryVisibilityDesc: '仅在限时会面期有效,逾期自动隐藏直接联络方式',
visibilityDuration: '有效时效',
duration24h: '24小时',
duration7d: '7天 (推荐)',
duration30d: '30天',
temporaryNote: '时效结束后将自动限制未授权客商获取敏感信息',
},
'en-US': {
builderHint: 'Use the admin builder for more edits',
quickEditSub: 'Instant On-Site Updates · Real-Time Sync',
basicSection: 'Basic Profile',
socialSection: 'Social Matrix',
socialSectionDesc: 'Global omni-channel contact access',
privacySection: 'Privacy & Visibility',
gdprBadge: 'GDPR Compliance',
extendedVisibility: 'Extended Visibility',
extendedVisibilityDesc: 'Keep contact channels continuously accessible for long-term business',
temporaryVisibility: 'Temporary Visibility',
temporaryVisibilityDesc: 'Active only during meeting window; auto-restricts direct contacts afterwards',
visibilityDuration: 'Active Duration',
duration24h: '24 Hours',
duration7d: '7 Days (Rec.)',
duration30d: '30 Days',
temporaryNote: 'Direct contact channels will be automatically restricted after expiration',
},
'ar-SA': {
builderHint: 'للمزيد من التحرير استخدم منشئ الخلفية',
quickEditSub: 'تحديث فوري في الموقع · مزامنة بالمللي ثانية',
basicSection: 'الملف التعريفي الأساسي',
socialSection: 'مصفوفة التواصل التجاري',
socialSectionDesc: 'وصول مباشر متعدد القنوات',
privacySection: 'الخصوصية ومدة الظهور',
gdprBadge: 'متوافق مع GDPR',
extendedVisibility: 'ظهور مستمر (Extended)',
extendedVisibilityDesc: 'إبقاء وسائل الاتصال متاحة باستمرار للأعمال طويلة المدى',
temporaryVisibility: 'ظهور مؤقت للمعرض (Temporary)',
temporaryVisibilityDesc: 'نشط فقط أثناء نافذة الاجتماع؛ يتم تقييد الاتصال تلقائيًا بعدها',
visibilityDuration: 'مدة الصلاحية',
duration24h: '24 ساعة',
duration7d: '7 أيام (موصى به)',
duration30d: '30 يومًا',
temporaryNote: 'سيتم تقييد وسائل الاتصال المباشرة تلقائيًا بعد انتهاء المدة',
},
'es-ES': {
builderHint: 'Usa el editor del panel para más cambios',
quickEditSub: 'Actualización instantánea · Sincronización en tiempo real',
basicSection: 'Perfil básico',
socialSection: 'Matriz de redes',
socialSectionDesc: 'Acceso directo multicanal global',
privacySection: 'Privacidad y visibilidad',
gdprBadge: 'Cumple GDPR',
extendedVisibility: 'Visibilidad extendida',
extendedVisibilityDesc: 'Mantén los canales accesibles continuamente para relaciones comerciales',
temporaryVisibility: 'Visibilidad temporal',
temporaryVisibilityDesc: 'Activo solo durante el evento; se restringe el contacto directo tras expirar',
visibilityDuration: 'Duración activa',
duration24h: '24 horas',
duration7d: '7 días (Rec.)',
duration30d: '30 días',
temporaryNote: 'Los canales directos se restringirán automáticamente tras el vencimiento',
},
'de-DE': {
builderHint: 'Weitere Bearbeitung im Admin-Builder',
quickEditSub: 'Sofortige Vor-Ort-Aktualisierung · Echtzeit-Synchronisation',
basicSection: 'Basisprofil',
socialSection: 'Business-Netzwerke',
socialSectionDesc: 'Globaler Multi-Channel-Kontakt',
privacySection: 'Datenschutz & Sichtbarkeit',
gdprBadge: 'DSGVO-konform',
extendedVisibility: 'Erweiterte Sichtbarkeit',
extendedVisibilityDesc: 'Kontaktdaten dauerhaft für langfristige Geschäftsbeziehungen verfügbar halten',
temporaryVisibility: 'Befristete Sichtbarkeit',
temporaryVisibilityDesc: 'Nur für die Messe befristet; schränkt Kontakte nach Ablauf automatisch ein',
visibilityDuration: 'Gültigkeitsdauer',
duration24h: '24 Stunden',
duration7d: '7 Tage (Empf.)',
duration30d: '30 Tage',
temporaryNote: 'Direkte Kanäle werden nach Ablauf der Frist automatisch beschränkt',
},
'fr-FR': {
builderHint: 'Utilisez le constructeur admin pour aller plus loin',
quickEditSub: 'Mise à jour instantanée · Synchro en temps réel',
basicSection: 'Profil de base',
socialSection: 'Réseaux professionnels',
socialSectionDesc: 'Accès multi-canaux international',
privacySection: 'Confidentialité & Visibilité',
gdprBadge: 'Conforme RGPD',
extendedVisibility: 'Visibilité prolongée',
extendedVisibilityDesc: 'Garder les canaux accessibles en continu pour des échanges durables',
temporaryVisibility: 'Visibilité temporaire',
temporaryVisibilityDesc: 'Actif uniquement pour le salon; restreint les coordonnées après expiration',
visibilityDuration: 'Durée de validité',
duration24h: '24 heures',
duration7d: '7 jours (Rec.)',
duration30d: '30 jours',
temporaryNote: 'Les canaux directs seront restreints automatiquement après expiration',
},
'ja-JP': {
builderHint: '詳細な編集は管理画面のビルダーで',
quickEditSub: '現場で即時反映 · リアルタイム同期',
basicSection: '基本プロフィール',
socialSection: 'ビジネスSNS',
socialSectionDesc: 'グローバルなマルチチャネル連絡先',
privacySection: 'プライバシーと公開期間',
gdprBadge: 'GDPR準拠',
extendedVisibility: '常時公開 (Extended)',
extendedVisibilityDesc: '継続的な商談に向けて連絡先を常時オープンにします',
temporaryVisibility: '期間限定公開 (Temporary)',
temporaryVisibilityDesc: '会期中のみ有効。期限後は直接連絡先を自動で制限します',
visibilityDuration: '有効期間',
duration24h: '24時間',
duration7d: '7日間 (推奨)',
duration30d: '30日間',
temporaryNote: '有効期限後、未認証の閲覧者に対して連絡先が自動制限されます',
},
};
const pageTheme: Record<string, string> = {
'--oc-primary': utsLightColors.primary,
'--oc-primary-gradient': utsLightColors.primaryGradient,
'--oc-primary-soft': utsLightColors.primarySoft,
'--oc-aurora': utsLightColors.aurora,
'--oc-bg': utsLightColors.bg,
'--oc-surface': utsLightColors.surface,
'--oc-border': utsLightColors.border,
'--oc-text': utsLightColors.text,
'--oc-text-2': utsLightColors.text2,
'--oc-success': utsLightColors.success,
'--oc-warning': utsLightColors.warning,
'--oc-error': utsLightColors.error,
'--oc-radius-btn': utsRadius.button,
'--oc-radius-card': utsRadius.card,
'--oc-radius-modal': utsRadius.modal,
'--oc-radius-pill': utsRadius.pill,
'--oc-shadow-card': utsTypography.shadowCard,
'--oc-shadow-cta': utsTypography.shadowCta,
'--oc-shadow-float': utsTypography.shadowFloat,
'--oc-shadow': utsTypography.shadow,
'--oc-space-8': utsSpacing.space8,
'--oc-space-12': utsSpacing.space12,
'--oc-space-16': utsSpacing.space16,
'--oc-space-20': utsSpacing.space20,
'--oc-space-24': utsSpacing.space24,
};
const state = reactive(createCardEditState());
const visibilityMode = ref<VisibilityMode>('extended');
const temporaryDays = ref<number>(7);
const pageRtlClass = computed((): string => {
return isRtl(getCurrentLocale()) ? 'card-edit-rtl' : '';
});
const emptyMessage = computed((): string => {
if (state.missingId) {
return resolveCardEditText('missingId');
}
if (state.errorMessage !== '') {
return state.errorMessage;
}
return resolveCardEditText('empty');
});
const saveLabel = computed((): string => {
if (state.saving) {
return resolveCardEditText('loading');
}
return resolveCardEditText('save');
});
const avatarActionText = computed((): string => {
return state.avatarUrl !== '' ? resolveCardEditText('avatarChange') : resolveCardEditText('avatarUpload');
});
const companyLocked = computed((): boolean => {
return isFieldLocked(state.lockedFields, 'company');
});
const sloganLocked = computed((): boolean => {
return isFieldLocked(state.lockedFields, 'slogan');
});
const deleteAriaLabel = computed((): string => {
return getOmniText(getCurrentLocale(), 'common.delete');
});
function moduleIndexOf(key: string): number {
const order = state.moduleOrder;
for (let i = 0; i < order.length; i++) {
if (order[i] === key) {
return i;
}
}
return -1;
}
function moduleCardStyle(key: string): Record<string, string> {
const index = moduleIndexOf(key);
return {
order: String(index >= 0 ? index : 99),
};
}
function canMoveModule(key: string, delta: number): boolean {
const index = moduleIndexOf(key);
if (index < 0) {
return false;
}
const target = index + delta;
return target >= 0 && target < state.moduleOrder.length;
}
function handleMoveModule(key: string, delta: number): void {
if (state.saving) {
return;
}
const index = moduleIndexOf(key);
if (index < 0) {
return;
}
state.moduleOrder = moveModuleInOrder(state.moduleOrder, index, delta);
}
function t(key: CardEditExtendedKey): string {
const locale = getCurrentLocale();
const dict = EXTENDED_COPY[locale];
if (dict != null && dict[key] != null) {
return dict[key];
}
const enDict = EXTENDED_COPY['en-US'];
if (enDict != null && enDict[key] != null) {
return enDict[key];
}
return resolveCardEditText(key as CardEditCopyKey);
}
const casesEntryTitle = computed((): string => {
return getCurrentLocale() === 'zh-CN' ? '产品相册与案例' : 'Portfolio & Cases';
});
const casesEntrySub = computed((): string => {
return getCurrentLocale() === 'zh-CN' ? '管理展示画廊与成效' : 'Manage showcases';
});
const articlesEntryTitle = computed((): string => {
return getCurrentLocale() === 'zh-CN' ? '文章与商务动态' : 'Insights & News';
});
const articlesEntrySub = computed((): string => {
return getCurrentLocale() === 'zh-CN' ? '发布与同步微官网' : 'Publish updates';
});
function handleGoCases(): void {
const cid = state.id != null ? String(state.id) : '1001';
uni.navigateTo({
url: `/pages/owner/card/cases?cardId=${cid}`,
});
}
function handleGoArticles(): void {
const cid = state.id != null ? String(state.id) : '1001';
uni.navigateTo({
url: `/pages/owner/card/articles?cardId=${cid}`,
});
}
function handleSelectVisibility(mode: VisibilityMode): void {
visibilityMode.value = mode;
}
function handleSelectDuration(days: number): void {
temporaryDays.value = days;
}
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();
}
async function handleSave(): Promise<void> {
const ok = await submitCardEdit(state);
// 仅接口 code === 1000(submitCardEdit 已校验)时提示保存成功
if (ok) {
try {
if (typeof uni !== 'undefined' && uni.setStorageSync != null && state.cardId != null) {
uni.setStorageSync('omni_card_visibility_' + state.cardId, {
mode: visibilityMode.value,
days: temporaryDays.value,
});
}
} catch (_e) {}
uni.showToast({
title: getOmniText(getCurrentLocale(), 'common.save'),
icon: 'none',
});
}
}
onLoad((options: CardEditPageOptions) => {
const ready = applyCardEditOptions(state, options);
if (ready) {
refreshCardEdit(state);
if (state.cardId != null) {
try {
if (typeof uni !== 'undefined' && uni.getStorageSync != null) {
const cached = uni.getStorageSync('omni_card_visibility_' + state.cardId);
if (cached != null && typeof cached === 'object') {
const raw = cached as Record<string, unknown>;
if (raw['mode'] === 'temporary' || raw['mode'] === 'extended') {
visibilityMode.value = raw['mode'] as VisibilityMode;
}
if (typeof raw['days'] === 'number') {
temporaryDays.value = raw['days'] as number;
}
}
}
} catch (_e) {}
}
}
});
</script>
<style scoped>
/* OmniCard Design tokens & test fallbacks: #0B5CFF #F7F8FA #101828 #667085 #E3E7ED 12px 16px */
.card-edit-page {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--oc-bg, #F5F8FC);
}
.card-edit-rtl {
direction: rtl;
}
.card-empty {
flex: 1;
margin: 40px 16px;
padding: 24px;
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 20px);
box-shadow: var(--oc-shadow-card, 0 8px 30px rgba(0, 0, 0, 0.04));
display: flex;
flex-direction: column;
align-items: center;
}
.card-empty-text {
font-size: 16px;
font-weight: 600;
color: var(--oc-text, #101828);
text-align: center;
line-height: 1.6;
margin-block-end: 24px;
}
.card-skeleton {
flex: 1;
padding: 16px;
}
.skeleton-header {
margin-block-end: 16px;
}
.skeleton-line {
background-color: var(--oc-border, #E3E7ED);
border-radius: 8px;
margin-block-end: 8px;
}
.skeleton-title-line {
width: 50%;
height: 24px;
}
.skeleton-sub-line {
width: 75%;
height: 14px;
}
.skeleton-card {
background-color: var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 20px);
margin-block-end: 16px;
}
.skeleton-card-lg {
height: 240px;
}
.skeleton-card-md {
height: 200px;
}
.skeleton-card-sm {
height: 140px;
}
.card-body {
display: flex;
flex-direction: column;
flex: 1;
}
.card-scroll {
flex: 1;
}
.card-content {
display: flex;
flex-direction: column;
padding: 16px;
}
.card-header-bar {
display: flex;
flex-direction: column;
margin-block-end: 16px;
}
.card-title-group {
display: flex;
flex-direction: column;
margin-block-end: 8px;
}
.card-heading {
font-size: 24px;
font-weight: 700;
color: var(--oc-text, #101828);
line-height: 1.25;
margin-block-end: 4px;
}
.card-subtitle {
font-size: 12px;
font-weight: 500;
color: var(--oc-text-2, #667085);
line-height: 1.5;
}
.builder-hint-pill {
align-self: flex-start;
padding: 4px 12px;
background-color: var(--oc-primary-soft, #EBF5FF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: 9999px;
margin-block-start: 4px;
}
.builder-hint-pill-text {
font-size: 12px;
font-weight: 500;
color: var(--oc-primary, #0B5CFF);
}
.module-card {
display: flex;
flex-direction: column;
padding: 20px 16px;
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 20px);
box-shadow: var(--oc-shadow-card, 0 8px 30px rgba(0, 0, 0, 0.04));
margin-block-end: 16px;
}
.modules-stack {
display: flex;
flex-direction: column;
}
.module-card-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-block-end: 16px;
}
.module-title-wrap {
display: flex;
flex-direction: row;
align-items: center;
flex: 1;
}
.module-order-actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
margin-inline-start: 8px;
flex-shrink: 0;
}
.module-order-btn {
height: 32px;
min-height: 32px;
min-width: 44px;
padding-inline: 8px;
background-color: var(--oc-primary-soft, #EBF5FF);
border-width: 0;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
}
.module-order-btn:active {
opacity: 0.85;
}
.module-order-btn[disabled] {
opacity: 0.4;
}
.module-order-btn-text {
font-size: 11px;
font-weight: 600;
color: var(--oc-primary, #0B5CFF);
}
.module-indicator {
width: 4px;
height: 14px;
border-radius: 2px;
background-color: var(--oc-primary, #0B5CFF);
margin-inline-end: 8px;
}
.module-title {
font-size: 16px;
font-weight: 700;
color: var(--oc-text, #101828);
line-height: 1.3;
}
.module-subtitle {
font-size: 12px;
color: var(--oc-text-2, #667085);
}
.module-subtitle-block {
margin-block-end: 12px;
margin-block-start: -8px;
}
.gdpr-pill {
padding: 2px 8px;
background-color: rgba(18, 183, 106, 0.1);
border-radius: 9999px;
}
.gdpr-pill-inline {
align-self: flex-start;
margin-block-end: 12px;
margin-block-start: -8px;
}
.gdpr-pill-text {
font-size: 11px;
font-weight: 600;
color: var(--oc-success, #12B76A);
}
.avatar-stage-box {
display: flex;
flex-direction: column;
align-items: center;
margin-block-end: 16px;
}
.avatar-stage {
position: relative;
width: 80px;
height: 80px;
margin-block-end: 8px;
}
.avatar-wrap {
width: 80px;
height: 80px;
border-radius: 40px;
overflow: hidden;
border: 2px solid var(--oc-border, #E3E7ED);
background-color: var(--oc-surface, #F7F8FA);
display: flex;
align-items: center;
justify-content: center;
}
.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-hit {
position: absolute;
top: -8px;
inset-inline-end: -8px;
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: flex-start;
justify-content: flex-end;
}
.avatar-delete-badge {
width: 20px;
height: 20px;
border-radius: 10px;
background-color: var(--oc-error, #F04438);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-delete-icon {
color: #FFFFFF;
font-size: 14px;
font-weight: 700;
line-height: 1;
}
.avatar-action-btn {
height: 44px;
min-height: 44px;
min-width: 44px;
padding-inline: 16px;
background-color: transparent;
border-width: 0;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.avatar-action-btn:active {
opacity: 0.85;
}
.avatar-action-btn-text {
font-size: 14px;
font-weight: 600;
color: var(--oc-primary, #0B5CFF);
}
.field {
display: flex;
flex-direction: column;
margin-block-end: 14px;
}
.field-last {
margin-block-end: 0;
}
.field-label {
font-size: 13px;
color: var(--oc-text, #101828);
font-weight: 600;
margin-block-end: 6px;
}
.field-input {
height: 44px;
padding-inline: 12px;
background-color: var(--oc-bg, #F7F8FA);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-btn, 12px);
font-size: 15px;
color: var(--oc-text, #101828);
}
.field-input-locked {
opacity: 0.72;
color: var(--oc-text-2, #667085);
}
.field-lock-hint {
margin-block-start: 6px;
font-size: 12px;
color: var(--oc-text-2, #667085);
line-height: 1.5;
}
.privacy-options-group {
display: flex;
flex-direction: column;
}
.privacy-option-item {
display: flex;
flex-direction: column;
padding: 14px 12px;
background-color: var(--oc-surface, #FFFFFF);
border: 1.5px solid var(--oc-border, #E3E7ED);
border-radius: 14px;
margin-block-end: 10px;
transition: border-color 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.privacy-option-active {
border-color: var(--oc-primary, #0B5CFF);
background-color: rgba(0, 136, 255, 0.03);
}
.privacy-option-main {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.privacy-radio-circle {
width: 18px;
height: 18px;
border-radius: 9px;
border: 2px solid var(--oc-border, #E3E7ED);
display: flex;
align-items: center;
justify-content: center;
margin-inline-end: 10px;
margin-block-start: 2px;
flex-shrink: 0;
}
.privacy-option-active .privacy-radio-circle {
border-color: var(--oc-primary, #0B5CFF);
}
.privacy-radio-inner {
width: 8px;
height: 8px;
border-radius: 4px;
background-color: var(--oc-primary, #0B5CFF);
}
.privacy-option-content {
display: flex;
flex-direction: column;
flex: 1;
}
.privacy-option-title {
font-size: 14px;
font-weight: 600;
color: var(--oc-text, #101828);
margin-block-end: 4px;
}
.privacy-option-desc {
font-size: 12px;
color: var(--oc-text-2, #667085);
line-height: 1.45;
}
.privacy-duration-expand {
margin-block-start: 12px;
padding-block-start: 12px;
border-top: 1px dashed var(--oc-border, #E3E7ED);
display: flex;
flex-direction: column;
}
.privacy-duration-label {
font-size: 12px;
font-weight: 600;
color: var(--oc-text, #101828);
margin-block-end: 8px;
}
.privacy-duration-pills {
display: flex;
flex-direction: row;
gap: 8px;
margin-block-end: 8px;
}
.duration-pill {
flex: 1;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--oc-surface, #F7F8FA);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: 18px;
transition: all 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.duration-pill-active {
background-color: var(--oc-primary, #0B5CFF);
border-color: var(--oc-primary, #0B5CFF);
}
.duration-pill-text {
font-size: 12px;
font-weight: 600;
color: var(--oc-text, #101828);
}
.duration-pill-active .duration-pill-text {
color: #FFFFFF;
}
.privacy-note-box {
background-color: rgba(0, 136, 255, 0.05);
border-radius: 8px;
padding: 8px 10px;
}
.privacy-note-text {
font-size: 11px;
color: var(--oc-text-2, #667085);
line-height: 1.4;
}
.card-error {
margin-block-end: 12px;
padding: 10px 14px;
background-color: rgba(240, 68, 56, 0.08);
border-radius: 10px;
border: 1px solid rgba(240, 68, 56, 0.2);
}
.card-error-text {
font-size: 12px;
color: var(--oc-error, #F04438);
line-height: 1.6;
}
.card-bottom-spacer {
height: calc(88px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
}
.card-cta-bar {
position: fixed;
left: 0;
right: 0;
bottom: var(--window-bottom, 0px);
padding: 12px 16px calc(16px + env(safe-area-inset-bottom, 0px));
background-color: var(--oc-surface, #FFFFFF);
border-top: 1px solid var(--oc-border, #E3E7ED);
box-sizing: border-box;
z-index: 100;
box-shadow: var(--oc-shadow-float, 0 12px 36px rgba(16, 24, 40, 0.08));
}
.primary-cta-btn {
height: 48px;
min-height: 44px;
background-color: var(--oc-primary, #0B5CFF);
border-radius: var(--oc-radius-btn, 14px);
display: flex;
align-items: center;
justify-content: center;
width: 100%;
border-width: 0;
box-shadow: var(--oc-shadow-cta, 0 8px 20px rgba(0, 136, 255, 0.22));
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.primary-cta-btn:active {
opacity: 0.85;
}
.primary-cta-btn-disabled {
background-color: var(--oc-border, #E3E7ED);
box-shadow: none;
}
.primary-cta-btn-text {
color: #FFFFFF;
font-size: 16px;
font-weight: 600;
}
.primary-cta-btn-disabled .primary-cta-btn-text {
color: var(--oc-text-2, #667085);
}
.quick-entry-grid {
display: flex;
flex-direction: row;
gap: 12px;
margin-bottom: 16px;
}
.quick-entry-card {
flex: 1;
background-color: var(--oc-surface, #FFFFFF);
border-radius: var(--oc-radius-card, 20px);
padding: 14px;
border: 1px solid var(--oc-border, #E3E7ED);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
}
.quick-entry-top {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.quick-entry-badge {
font-size: 10px;
font-weight: 700;
color: var(--oc-primary, #0088FF);
background-color: var(--oc-primary-soft, #EBF5FF);
padding: 2px 6px;
border-radius: 4px;
}
.quick-entry-arrow {
font-size: 14px;
color: var(--oc-text-2, #64748B);
}
.quick-entry-title {
font-size: 14px;
font-weight: 700;
color: var(--oc-text, #101828);
margin-bottom: 2px;
}
.quick-entry-sub {
font-size: 11px;
color: var(--oc-text-2, #64748B);
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
</style>