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

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

731 行
17 KiB
Plaintext

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

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

<template>
<view class="cases-page" :class="pageRtlClass" :style="pageTheme">
<!-- 顶部极光微光晕 -->
<view class="ambient-aurora-glow"></view>
<!-- 顶部状态栏与标题导航 -->
<view class="cases-header-bar">
<view class="cases-title-group">
<text class="cases-heading">{{ t('title') }}</text>
<text class="cases-subtitle">{{ t('subtitle') }}</text>
</view>
</view>
<!-- 骨架屏加载态 -->
<view v-if="state.loading" class="cases-skeleton">
<view class="skeleton-card"></view>
<view class="skeleton-card"></view>
</view>
<!-- 空状态展示 -->
<view v-else-if="state.empty" class="cases-empty-card">
<view class="empty-badge">
<text class="empty-badge-icon">!</text>
</view>
<text class="empty-title">{{ t('emptyTitle') }}</text>
<text class="empty-desc">{{ t('emptyDesc') }}</text>
</view>
<!-- 案例与相册列表 -->
<scroll-view v-else class="cases-scroll" :scroll-y="true">
<view class="cases-list">
<view v-for="item in state.list" :key="item.id" class="case-item-card">
<image
v-if="item.coverImage != null && item.coverImage !== ''"
class="case-item-cover"
:src="item.coverImage!"
mode="aspectFill"
/>
<view v-else class="case-item-cover-placeholder">
<text class="placeholder-text">{{ t('coverPlaceholder') }}</text>
</view>
<view class="case-item-body">
<view class="case-item-top">
<text class="case-item-title">{{ item.title }}</text>
<view v-if="item.metrics != null && item.metrics !== ''" class="case-metrics-pill">
<text class="case-metrics-text">{{ item.metrics }}</text>
</view>
</view>
<view v-if="item.client != null && item.client !== ''" class="case-item-meta">
<text class="case-meta-label">{{ t('clientPrefix') }}</text>
<text class="case-meta-value">{{ item.client }}</text>
</view>
<text v-if="item.description != null && item.description !== ''" class="case-item-desc">
{{ item.description }}
</text>
<view class="case-item-actions">
<button class="action-btn action-btn-edit" @click="handleOpenEdit(item)">
<text class="action-btn-text">{{ t('editBtn') }}</text>
</button>
<button class="action-btn action-btn-delete" @click="handleDelete(item)">
<text class="action-btn-delete-text">{{ t('deleteBtn') }}</text>
</button>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 底部常驻主 CTA 悬浮栏 -->
<view class="cases-bottom-bar">
<button class="primary-cta-btn" @click="handleOpenAdd">
<text class="primary-cta-btn-text">{{ t('addBtn') }}</text>
</button>
</view>
<!-- 底部编辑抽屉 (Bottom Sheet) -->
<view v-if="state.drawerVisible" class="drawer-mask" @click="handleCloseDrawer">
<view class="drawer-sheet" @click.stop="">
<view class="drawer-handle-bar">
<view class="drawer-handle"></view>
</view>
<view class="drawer-header">
<text class="drawer-title">{{ isEditing ? t('editBtn') : t('addBtn') }}</text>
<text class="drawer-close" @click="handleCloseDrawer">&times;</text>
</view>
<scroll-view class="drawer-scroll" :scroll-y="true">
<view class="drawer-form">
<!-- 标题 -->
<view class="form-field">
<text class="form-label">{{ t('fieldTitle') }} *</text>
<input
class="form-input"
type="text"
v-model="editForm.title"
:placeholder="t('phTitle')"
/>
</view>
<!-- 客户名称 -->
<view class="form-field">
<text class="form-label">{{ t('fieldClient') }}</text>
<input
class="form-input"
type="text"
v-model="editForm.client"
:placeholder="t('phClient')"
/>
</view>
<!-- 项目周期 -->
<view class="form-field">
<text class="form-label">{{ t('fieldDuration') }}</text>
<input
class="form-input"
type="text"
v-model="editForm.duration"
:placeholder="t('phDuration')"
/>
</view>
<!-- 成效标签(用户自填,禁止预填假 KPI) -->
<view class="form-field">
<text class="form-label">{{ t('fieldMetrics') }}</text>
<input
class="form-input"
type="text"
v-model="editForm.metrics"
:placeholder="t('phMetrics')"
/>
</view>
<!-- 封面图 / 相册上传 -->
<view class="form-field">
<text class="form-label">{{ t('fieldCover') }}</text>
<image-uploader
:max-count="4"
:model-value="uploadedImages"
@update:modelValue="handleImagesChange"
/>
</view>
<!-- 方案成果简述 -->
<view class="form-field">
<text class="form-label">{{ t('fieldDesc') }}</text>
<textarea
class="form-textarea"
v-model="editForm.description"
:placeholder="t('phDesc')"
maxlength="500"
></textarea>
</view>
</view>
</scroll-view>
<!-- 抽屉底部操作条 -->
<view class="drawer-action-bar">
<button class="drawer-btn-cancel" @click="handleCloseDrawer">
<text class="drawer-btn-cancel-text">{{ t('cancelBtn') }}</text>
</button>
<button
class="primary-cta-btn drawer-btn-save"
:disabled="state.saving"
@click="handleSave"
>
<text class="primary-cta-btn-text">{{ state.saving ? t('saving') : t('saveBtn') }}</text>
</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { CardCase } from '../../../common/types.uts';
import {
createCasesState,
loadCases,
saveCase,
deleteCase,
resolveCasesText,
CasesCopyKey,
} from './useCardCases.uts';
import ImageUploader from '../../../components/image-uploader/image-uploader.uvue';
import { getCurrentLocale, isRtl } from '../../../common/locale.uts';
import { utsLightColors, utsRadius, utsSpacing, utsTypography } from '../../../common/tokens.uts';
const currentCardId = ref<string>('1001');
const state = reactive(createCasesState('1001'));
const editForm = reactive<Partial<CardCase>>({
title: '',
client: '',
duration: '',
metrics: '',
coverImage: '',
description: '',
});
const uploadedImages = ref<string[]>([]);
const isEditing = computed<boolean>(() => {
return editForm.id != null;
});
const pageRtlClass = computed<string>(() => {
return isRtl(getCurrentLocale()) ? 'page-rtl' : '';
});
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.btn,
'--oc-radius-card': utsRadius.card,
'--oc-radius-sheet': utsRadius.sheet,
'--oc-space-16': utsSpacing.s16,
'--oc-space-24': utsSpacing.s24,
'--oc-font-title': utsTypography.title,
'--oc-font-body': utsTypography.body,
'--oc-font-caption': utsTypography.caption,
};
function t(key: CasesCopyKey): string {
return resolveCasesText(key);
}
function handleOpenAdd(): void {
editForm.id = undefined;
editForm.title = '';
editForm.client = '';
editForm.duration = '';
editForm.metrics = '';
editForm.coverImage = '';
editForm.description = '';
uploadedImages.value = [];
state.drawerVisible = true;
}
function handleOpenEdit(item: CardCase): void {
editForm.id = item.id;
editForm.title = item.title;
editForm.client = item.client || '';
editForm.duration = item.duration || '';
editForm.metrics = item.metrics || '';
editForm.coverImage = item.coverImage || '';
editForm.description = item.description || '';
uploadedImages.value = item.coverImage ? [item.coverImage] : [];
state.drawerVisible = true;
}
function handleCloseDrawer(): void {
state.drawerVisible = false;
}
function handleImagesChange(imgs: string[]): void {
uploadedImages.value = imgs;
if (imgs.length > 0) {
editForm.coverImage = imgs[0];
} else {
editForm.coverImage = '';
}
}
async function handleSave(): Promise<void> {
const success = await saveCase(state, editForm);
if (success) {
uni.showToast({
title: t('savedToast'),
icon: 'none',
});
} else if (state.errorMessage !== '') {
uni.showToast({
title: state.errorMessage,
icon: 'none',
});
}
}
async function handleDelete(item: CardCase): Promise<void> {
if (item.id == null) return;
uni.showModal({
title: t('deleteConfirmTitle'),
content: t('deleteConfirmContent'),
confirmText: t('deleteConfirmOk'),
cancelText: t('cancelBtn'),
success: async (res: any) => {
if (res.confirm) {
const ok = await deleteCase(state, item.id!);
if (ok) {
uni.showToast({
title: t('deletedToast'),
icon: 'none',
});
}
}
},
});
}
onLoad((options: any) => {
const id = options && (options.cardId || options.id) ? String(options.cardId || options.id) : '1001';
currentCardId.value = id;
state.cardId = id;
loadCases(state);
});
</script>
<style scoped>
.cases-page {
position: relative;
min-height: 100vh;
background-color: var(--oc-bg);
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.ambient-aurora-glow {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 160px;
background: var(--oc-aurora);
opacity: 0.08;
pointer-events: none;
}
.cases-header-bar {
padding: 20px 16px 12px 16px;
position: relative;
z-index: 1;
}
.cases-heading {
font-size: 22px;
font-weight: 700;
color: var(--oc-text);
line-height: 1.3;
}
.cases-subtitle {
font-size: 13px;
color: var(--oc-text-2);
margin-top: 6px;
line-height: 1.5;
}
.cases-scroll {
flex: 1;
padding: 0 16px;
box-sizing: border-box;
}
.cases-list {
padding-bottom: 96px;
display: flex;
flex-direction: column;
gap: 14px;
}
.case-item-card {
background-color: var(--oc-surface);
border-radius: var(--oc-radius-card);
overflow: hidden;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.04), 0 2px 8px rgba(11, 99, 229, 0.03);
border: 1px solid var(--oc-border);
display: flex;
flex-direction: column;
}
.case-item-cover {
width: 100%;
height: 160px;
background-color: #EBF1F7;
}
.case-item-cover-placeholder {
width: 100%;
height: 100px;
background-color: var(--oc-primary-soft);
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-text {
font-size: 14px;
font-weight: 600;
color: var(--oc-primary);
}
.case-item-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.case-item-top {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.case-item-title {
font-size: 16px;
font-weight: 700;
color: var(--oc-text);
flex: 1;
line-height: 1.4;
}
.case-metrics-pill {
background: var(--oc-primary-soft);
padding: 4px 10px;
border-radius: 9999px;
}
.case-metrics-text {
font-size: 12px;
font-weight: 600;
color: var(--oc-primary);
}
.case-item-meta {
display: flex;
flex-direction: row;
font-size: 13px;
}
.case-meta-label {
color: var(--oc-text-2);
}
.case-meta-value {
color: var(--oc-text);
font-weight: 500;
margin-left: 4px;
}
.case-item-desc {
font-size: 13px;
color: var(--oc-text-2);
line-height: 1.5;
}
.case-item-actions {
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 10px;
margin-top: 8px;
border-top: 1px solid var(--oc-border);
padding-top: 10px;
}
.action-btn {
height: 44px;
min-height: 44px;
padding: 0 16px;
border-radius: var(--oc-radius-btn);
display: flex;
align-items: center;
justify-content: center;
border: none;
}
.action-btn-edit {
background-color: var(--oc-primary-soft);
}
.action-btn-text {
font-size: 13px;
font-weight: 600;
color: var(--oc-primary);
}
.action-btn-delete {
background-color: #FFF1F0;
}
.action-btn-delete-text {
font-size: 13px;
font-weight: 600;
color: var(--oc-error);
}
.cases-empty-card {
margin: 40px 16px;
padding: 32px 20px;
background-color: var(--oc-surface);
border-radius: var(--oc-radius-card);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.04);
}
.empty-badge {
width: 48px;
height: 48px;
border-radius: 24px;
background-color: var(--oc-primary-soft);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.empty-badge-icon {
font-size: 20px;
font-weight: 700;
color: var(--oc-primary);
}
.empty-title {
font-size: 16px;
font-weight: 700;
color: var(--oc-text);
margin-bottom: 6px;
}
.empty-desc {
font-size: 13px;
color: var(--oc-text-2);
line-height: 1.5;
}
.cases-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
border-top: 1px solid var(--oc-border);
z-index: 10;
}
.primary-cta-btn {
width: 100%;
height: 48px;
min-height: 44px;
background: var(--oc-primary-gradient);
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
border: none;
box-shadow: 0 8px 20px rgba(0, 136, 255, 0.22);
}
.primary-cta-btn-text {
font-size: 16px;
font-weight: 600;
color: #FFFFFF;
}
/* 抽屉样式 */
.drawer-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 100;
display: flex;
justify-content: flex-end;
flex-direction: column;
}
.drawer-sheet {
background-color: var(--oc-surface);
border-top-left-radius: var(--oc-radius-sheet);
border-top-right-radius: var(--oc-radius-sheet);
max-height: 85vh;
display: flex;
flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
}
.drawer-handle-bar {
padding-top: 10px;
display: flex;
justify-content: center;
}
.drawer-handle {
width: 36px;
height: 4px;
background-color: #D0D5DD;
border-radius: 2px;
}
.drawer-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 12px 20px 16px 20px;
border-bottom: 1px solid var(--oc-border);
}
.drawer-title {
font-size: 17px;
font-weight: 700;
color: var(--oc-text);
}
.drawer-close {
font-size: 24px;
color: var(--oc-text-2);
line-height: 1;
padding: 4px 8px;
}
.drawer-scroll {
max-height: 60vh;
padding: 16px 20px;
box-sizing: border-box;
}
.drawer-form {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 20px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
font-size: 14px;
font-weight: 600;
color: var(--oc-text);
}
.form-input {
height: 44px;
min-height: 44px;
font-size: 16px;
border: 1px solid var(--oc-border);
border-radius: var(--oc-radius-btn);
padding: 0 14px;
background-color: #FAFAFA;
color: var(--oc-text);
}
.form-textarea {
font-size: 16px;
border: 1px solid var(--oc-border);
border-radius: var(--oc-radius-btn);
padding: 12px 14px;
background-color: #FAFAFA;
color: var(--oc-text);
height: 90px;
}
.drawer-action-bar {
display: flex;
flex-direction: row;
gap: 12px;
padding: 12px 20px;
border-top: 1px solid var(--oc-border);
}
.drawer-btn-cancel {
flex: 1;
height: 46px;
min-height: 44px;
background-color: #F2F4F7;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
border: none;
}
.drawer-btn-cancel-text {
font-size: 15px;
font-weight: 600;
color: var(--oc-text-2);
}
.drawer-btn-save {
flex: 2;
height: 46px;
min-height: 44px;
}
.skeleton-card {
height: 120px;
background: #EAECF0;
border-radius: var(--oc-radius-card);
margin: 16px;
animation: pulse 1.5s infinite ease-in-out;
}
@keyframes pulse {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
</style>