feat(mpuniapp): 支持在移动端App维护产品相册与文章动态
这个提交包含在:
@@ -6,6 +6,8 @@
|
||||
import {
|
||||
AiChatReply,
|
||||
AiChatRequest,
|
||||
CardArticle,
|
||||
CardCase,
|
||||
CardContact,
|
||||
CardEvent,
|
||||
CardEventJoinResult,
|
||||
@@ -248,5 +250,53 @@ export function confirmHallExchange(
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchCardCasesPage(
|
||||
query: OwnerPageQuery
|
||||
): Promise<OwnerPageData<CardCase>> {
|
||||
return httpPost<OwnerPageData<CardCase>>('/admin/card/cases/page', query);
|
||||
}
|
||||
|
||||
export function fetchCardCaseInfo(id: string | number): Promise<CardCase> {
|
||||
return httpGet<CardCase>('/admin/card/cases/info', { id });
|
||||
}
|
||||
|
||||
export function addCardCase(data: Partial<CardCase>): Promise<CardCase> {
|
||||
return httpPost<CardCase>('/admin/card/cases/add', data);
|
||||
}
|
||||
|
||||
export function updateCardCase(
|
||||
data: Partial<CardCase> & { id: string | number }
|
||||
): Promise<CardCase> {
|
||||
return httpPost<CardCase>('/admin/card/cases/update', data);
|
||||
}
|
||||
|
||||
export function deleteCardCase(id: string | number): Promise<boolean> {
|
||||
return httpPost<boolean>('/admin/card/cases/delete', { ids: [id] });
|
||||
}
|
||||
|
||||
export function fetchCardArticlesPage(
|
||||
query: OwnerPageQuery
|
||||
): Promise<OwnerPageData<CardArticle>> {
|
||||
return httpPost<OwnerPageData<CardArticle>>('/admin/card/article/page', query);
|
||||
}
|
||||
|
||||
export function fetchCardArticleInfo(id: string | number): Promise<CardArticle> {
|
||||
return httpGet<CardArticle>('/admin/card/article/info', { id });
|
||||
}
|
||||
|
||||
export function addCardArticle(data: Partial<CardArticle>): Promise<CardArticle> {
|
||||
return httpPost<CardArticle>('/admin/card/article/add', data);
|
||||
}
|
||||
|
||||
export function updateCardArticle(
|
||||
data: Partial<CardArticle> & { id: string | number }
|
||||
): Promise<CardArticle> {
|
||||
return httpPost<CardArticle>('/admin/card/article/update', data);
|
||||
}
|
||||
|
||||
export function deleteCardArticle(id: string | number): Promise<boolean> {
|
||||
return httpPost<boolean>('/admin/card/article/delete', { ids: [id] });
|
||||
}
|
||||
|
||||
export * from './types.uts';
|
||||
export * from './request.uts';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - 用户端跨端核心数据契约与 TypeScript / UTS 类型体系
|
||||
* 严格遵循 AGENTS.md 规范与出海 SaaS 契约,与 backend / admin 镜像对齐
|
||||
* 严格遵循 AGENTS.md 规范与全球数字名片 SaaS 契约,与 backend / admin 镜像对齐
|
||||
*/
|
||||
|
||||
export type CardStatus = 'draft' | 'published' | 'archived';
|
||||
@@ -294,3 +294,32 @@ export interface AiChatReply {
|
||||
locale: string;
|
||||
shouldCollectLead: boolean;
|
||||
}
|
||||
|
||||
export interface CardCase {
|
||||
id?: string | number;
|
||||
tenantId?: string | number;
|
||||
cardId: string | number;
|
||||
title: string;
|
||||
client?: string;
|
||||
duration?: string;
|
||||
metrics?: string;
|
||||
coverImage?: string;
|
||||
pdfUrl?: string;
|
||||
description?: string;
|
||||
sortOrder?: number;
|
||||
createTime?: string | Date;
|
||||
updateTime?: string | Date;
|
||||
}
|
||||
|
||||
export interface CardArticle {
|
||||
id?: string | number;
|
||||
cardId: string | number;
|
||||
title: string;
|
||||
summary?: string;
|
||||
content: string;
|
||||
coverImage?: string;
|
||||
isPublished: boolean;
|
||||
ogMeta?: Record<string, any>;
|
||||
createTime?: Date | string;
|
||||
updateTime?: Date | string;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,20 @@
|
||||
"backgroundColorContent": "#F7F8FA"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/owner/card/cases",
|
||||
"style": {
|
||||
"navigationBarTitleText": "产品相册",
|
||||
"backgroundColorContent": "#F7F8FA"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/owner/card/articles",
|
||||
"style": {
|
||||
"navigationBarTitleText": "文章动态",
|
||||
"backgroundColorContent": "#F7F8FA"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/owner/leads-detail",
|
||||
"style": {
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
<template>
|
||||
<view class="articles-page" :class="pageRtlClass" :style="pageTheme">
|
||||
<!-- 顶部极光微光晕 -->
|
||||
<view class="ambient-aurora-glow"></view>
|
||||
|
||||
<!-- 顶部标题栏 -->
|
||||
<view class="articles-header-bar">
|
||||
<view class="articles-title-group">
|
||||
<text class="articles-heading">{{ t('title') }}</text>
|
||||
<text class="articles-subtitle">{{ t('subtitle') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 骨架屏加载态 -->
|
||||
<view v-if="state.loading" class="articles-skeleton">
|
||||
<view class="skeleton-card"></view>
|
||||
<view class="skeleton-card"></view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态展示 -->
|
||||
<view v-else-if="state.empty" class="articles-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="articles-scroll" :scroll-y="true">
|
||||
<view class="articles-list">
|
||||
<view v-for="item in state.list" :key="item.id" class="article-item-card">
|
||||
<image
|
||||
v-if="item.coverImage != null && item.coverImage !== ''"
|
||||
class="article-item-cover"
|
||||
:src="item.coverImage!"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
|
||||
<view class="article-item-body">
|
||||
<view class="article-item-header">
|
||||
<text class="article-item-title">{{ item.title }}</text>
|
||||
<view
|
||||
class="article-status-pill"
|
||||
:class="item.isPublished ? 'status-pill-published' : 'status-pill-draft'"
|
||||
@click="handleToggleStatus(item)"
|
||||
>
|
||||
<text class="article-status-text">
|
||||
{{ item.isPublished ? t('statusPublished') : t('statusDraft') }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text v-if="item.summary != null && item.summary !== ''" class="article-item-summary">
|
||||
{{ item.summary }}
|
||||
</text>
|
||||
|
||||
<view class="article-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="articles-bottom-bar">
|
||||
<button class="primary-cta-btn" @click="handleOpenAdd">
|
||||
<text class="primary-cta-btn-text">{{ t('publishBtn') }}</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('publishBtn') }}</text>
|
||||
<text class="drawer-close" @click="handleCloseDrawer">×</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="例如:2026 全球智能硬件合规白皮书"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 摘要导读 -->
|
||||
<view class="form-field">
|
||||
<text class="form-label">{{ t('fieldSummary') }}</text>
|
||||
<input
|
||||
class="form-input"
|
||||
type="text"
|
||||
v-model="editForm.summary"
|
||||
placeholder="若不填写将自动从正文中提取摘要..."
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 封面图 -->
|
||||
<view class="form-field">
|
||||
<text class="form-label">{{ t('fieldCover') }}</text>
|
||||
<image-uploader
|
||||
:max-count="1"
|
||||
:model-value="uploadedCover"
|
||||
@update:modelValue="handleCoverChange"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 正文内容 -->
|
||||
<view class="form-field">
|
||||
<text class="form-label">{{ t('fieldContent') }} *</text>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
v-model="editForm.content"
|
||||
placeholder="输入正文内容,支持段落与 Markdown..."
|
||||
maxlength="2000"
|
||||
></textarea>
|
||||
</view>
|
||||
|
||||
<!-- 公开发布状态 -->
|
||||
<view class="form-field-switch">
|
||||
<view class="switch-info">
|
||||
<text class="switch-title">{{ t('fieldPublish') }}</text>
|
||||
<text class="switch-desc">{{ publishDescText }}</text>
|
||||
</view>
|
||||
<switch
|
||||
:checked="editForm.isPublished"
|
||||
color="#0088FF"
|
||||
@change="handleSwitchChange"
|
||||
/>
|
||||
</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 ? savingText : t('saveBtn') }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { CardArticle } from '../../../common/types.uts';
|
||||
import {
|
||||
createArticlesState,
|
||||
loadArticles,
|
||||
saveArticle,
|
||||
deleteArticle,
|
||||
toggleArticlePublish,
|
||||
resolveArticlesText,
|
||||
ArticlesCopyKey,
|
||||
} from './useCardArticles.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(createArticlesState('1001'));
|
||||
|
||||
const editForm = reactive<Partial<CardArticle>>({
|
||||
title: '',
|
||||
summary: '',
|
||||
coverImage: '',
|
||||
content: '',
|
||||
isPublished: true,
|
||||
});
|
||||
|
||||
const uploadedCover = ref<string[]>([]);
|
||||
|
||||
const isEditing = computed<boolean>(() => {
|
||||
return editForm.id != null;
|
||||
});
|
||||
|
||||
const pageRtlClass = computed<string>(() => {
|
||||
return isRtl(getCurrentLocale()) ? 'page-rtl' : '';
|
||||
});
|
||||
|
||||
const publishDescText = computed<string>(() => {
|
||||
return getCurrentLocale() === 'zh-CN'
|
||||
? '开启后,全球访客可在您的名片微官网中直接浏览'
|
||||
: 'When enabled, visitors can view this insight on your micro-site';
|
||||
});
|
||||
|
||||
const savingText = computed<string>(() => {
|
||||
return getCurrentLocale() === 'zh-CN' ? '保存中...' : 'Saving...';
|
||||
});
|
||||
|
||||
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: ArticlesCopyKey): string {
|
||||
return resolveArticlesText(key);
|
||||
}
|
||||
|
||||
function handleOpenAdd(): void {
|
||||
editForm.id = undefined;
|
||||
editForm.title = '';
|
||||
editForm.summary = '';
|
||||
editForm.coverImage = '';
|
||||
editForm.content = '';
|
||||
editForm.isPublished = true;
|
||||
uploadedCover.value = [];
|
||||
state.drawerVisible = true;
|
||||
}
|
||||
|
||||
function handleOpenEdit(item: CardArticle): void {
|
||||
editForm.id = item.id;
|
||||
editForm.title = item.title;
|
||||
editForm.summary = item.summary || '';
|
||||
editForm.coverImage = item.coverImage || '';
|
||||
editForm.content = item.content || '';
|
||||
editForm.isPublished = item.isPublished;
|
||||
uploadedCover.value = item.coverImage ? [item.coverImage] : [];
|
||||
state.drawerVisible = true;
|
||||
}
|
||||
|
||||
function handleCloseDrawer(): void {
|
||||
state.drawerVisible = false;
|
||||
}
|
||||
|
||||
function handleCoverChange(imgs: string[]): void {
|
||||
uploadedCover.value = imgs;
|
||||
if (imgs.length > 0) {
|
||||
editForm.coverImage = imgs[0];
|
||||
} else {
|
||||
editForm.coverImage = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleSwitchChange(e: any): void {
|
||||
editForm.isPublished = Boolean(e.detail.value);
|
||||
}
|
||||
|
||||
async function handleToggleStatus(item: CardArticle): Promise<void> {
|
||||
const ok = await toggleArticlePublish(state, item);
|
||||
if (ok) {
|
||||
uni.showToast({
|
||||
title: getCurrentLocale() === 'zh-CN' ? '状态已切换' : 'Status Updated',
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
const success = await saveArticle(state, editForm);
|
||||
if (success) {
|
||||
uni.showToast({
|
||||
title: getCurrentLocale() === 'zh-CN' ? '动态已保存' : 'Article Saved',
|
||||
icon: 'none',
|
||||
});
|
||||
} else if (state.errorMessage !== '') {
|
||||
uni.showToast({
|
||||
title: state.errorMessage,
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: CardArticle): void {
|
||||
if (item.id == null) return;
|
||||
const zh = getCurrentLocale() === 'zh-CN';
|
||||
uni.showModal({
|
||||
title: zh ? '确认删除' : 'Confirm Delete',
|
||||
content: zh ? '确定要删除该文章动态吗?' : 'Are you sure you want to delete this article?',
|
||||
confirmText: zh ? '删除' : 'Delete',
|
||||
cancelText: zh ? '取消' : 'Cancel',
|
||||
success: async (res: any) => {
|
||||
if (res.confirm) {
|
||||
const ok = await deleteArticle(state, item.id!);
|
||||
if (ok) {
|
||||
uni.showToast({
|
||||
title: zh ? '已删除' : 'Deleted',
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onLoad((options: any) => {
|
||||
const id = options && (options.cardId || options.id) ? String(options.cardId || options.id) : '1001';
|
||||
currentCardId.value = id;
|
||||
state.cardId = id;
|
||||
loadArticles(state);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.articles-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;
|
||||
}
|
||||
|
||||
.articles-header-bar {
|
||||
padding: 20px 16px 12px 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.articles-heading {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--oc-text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.articles-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--oc-text-2);
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.articles-scroll {
|
||||
flex: 1;
|
||||
padding: 0 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.articles-list {
|
||||
padding-bottom: 96px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.article-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;
|
||||
}
|
||||
|
||||
.article-item-cover {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
background-color: #EBF1F7;
|
||||
}
|
||||
|
||||
.article-item-body {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.article-item-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.article-item-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--oc-text);
|
||||
flex: 1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.article-status-pill {
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.status-pill-published {
|
||||
background-color: #ECFDF3;
|
||||
}
|
||||
|
||||
.status-pill-draft {
|
||||
background-color: #F2F4F7;
|
||||
}
|
||||
|
||||
.article-status-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--oc-text);
|
||||
}
|
||||
|
||||
.article-item-summary {
|
||||
font-size: 13px;
|
||||
color: var(--oc-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.article-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);
|
||||
}
|
||||
|
||||
.articles-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;
|
||||
}
|
||||
|
||||
.articles-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-field-switch {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--oc-border);
|
||||
}
|
||||
|
||||
.switch-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.switch-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--oc-text);
|
||||
}
|
||||
|
||||
.switch-desc {
|
||||
font-size: 12px;
|
||||
color: var(--oc-text-2);
|
||||
}
|
||||
|
||||
.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: 120px;
|
||||
}
|
||||
|
||||
.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>
|
||||
785
mpuniapp/pages/owner/card/cases.uvue
普通文件
785
mpuniapp/pages/owner/card/cases.uvue
普通文件
@@ -0,0 +1,785 @@
|
||||
<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">{{ placeholderCoverText }}</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">{{ clientPrefixText }}</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">×</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="例如:全球智能硬件出海交付方案"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 客户名称 -->
|
||||
<view class="form-field">
|
||||
<text class="form-label">{{ t('fieldClient') }}</text>
|
||||
<input
|
||||
class="form-input"
|
||||
type="text"
|
||||
v-model="editForm.client"
|
||||
placeholder="例如:阿联酋国际商业联合会"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 项目周期 -->
|
||||
<view class="form-field">
|
||||
<text class="form-label">{{ t('fieldDuration') }}</text>
|
||||
<input
|
||||
class="form-input"
|
||||
type="text"
|
||||
v-model="editForm.duration"
|
||||
placeholder="例如:2025.06 - 2025.12"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 量化成效标签快捷选择 -->
|
||||
<view class="form-field">
|
||||
<text class="form-label">{{ t('fieldMetrics') }}</text>
|
||||
<view class="metrics-pill-group">
|
||||
<view
|
||||
v-for="metric in PRESET_CASE_METRICS"
|
||||
:key="metric"
|
||||
class="preset-metric-pill"
|
||||
:class="{ 'preset-metric-pill-active': editForm.metrics === metric }"
|
||||
@click="handleSelectMetric(metric)"
|
||||
>
|
||||
<text class="preset-metric-text">{{ metric }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<input
|
||||
class="form-input"
|
||||
type="text"
|
||||
v-model="editForm.metrics"
|
||||
placeholder="或自定义输入成效,如:触达 10,000+ 买家"
|
||||
/>
|
||||
</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="简述方案背景、关键成效或客户反馈..."
|
||||
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 ? savingText : t('saveBtn') }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { CardCase } from '../../../common/types.uts';
|
||||
import {
|
||||
createCasesState,
|
||||
loadCases,
|
||||
saveCase,
|
||||
deleteCase,
|
||||
PRESET_CASE_METRICS,
|
||||
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 clientPrefixText = computed<string>(() => {
|
||||
return getCurrentLocale() === 'zh-CN' ? '合作客户: ' : 'Client: ';
|
||||
});
|
||||
|
||||
const placeholderCoverText = computed<string>(() => {
|
||||
return getCurrentLocale() === 'zh-CN' ? '产品展示' : 'Showcase';
|
||||
});
|
||||
|
||||
const savingText = computed<string>(() => {
|
||||
return getCurrentLocale() === 'zh-CN' ? '保存中...' : 'Saving...';
|
||||
});
|
||||
|
||||
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 handleSelectMetric(val: string): void {
|
||||
editForm.metrics = val;
|
||||
}
|
||||
|
||||
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: getCurrentLocale() === 'zh-CN' ? '案例已保存' : 'Case Saved',
|
||||
icon: 'none',
|
||||
});
|
||||
} else if (state.errorMessage !== '') {
|
||||
uni.showToast({
|
||||
title: state.errorMessage,
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: CardCase): void {
|
||||
if (item.id == null) return;
|
||||
const zh = getCurrentLocale() === 'zh-CN';
|
||||
uni.showModal({
|
||||
title: zh ? '确认删除' : 'Confirm Delete',
|
||||
content: zh ? '确定要删除该案例吗?此操作无法撤销。' : 'Are you sure you want to delete this case?',
|
||||
confirmText: zh ? '删除' : 'Delete',
|
||||
cancelText: zh ? '取消' : 'Cancel',
|
||||
success: async (res: any) => {
|
||||
if (res.confirm) {
|
||||
const ok = await deleteCase(state, item.id!);
|
||||
if (ok) {
|
||||
uni.showToast({
|
||||
title: zh ? '已删除' : 'Deleted',
|
||||
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;
|
||||
}
|
||||
|
||||
.metrics-pill-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.preset-metric-pill {
|
||||
background-color: var(--oc-bg);
|
||||
border: 1px solid var(--oc-border);
|
||||
padding: 6px 12px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.preset-metric-pill-active {
|
||||
background-color: var(--oc-primary-soft);
|
||||
border-color: var(--oc-primary);
|
||||
}
|
||||
|
||||
.preset-metric-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--oc-text);
|
||||
}
|
||||
|
||||
.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>
|
||||
文件差异内容过多而无法显示
加载差异
文件差异内容过多而无法显示
加载差异
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - 文章动态中心移动端业务逻辑驱动层
|
||||
* 严格遵循 uts 强类型契约与 AGENTS.md 规范
|
||||
*/
|
||||
|
||||
import { CardArticle, OwnerPageData, OwnerPageQuery } from '../../../common/types.uts';
|
||||
import {
|
||||
fetchCardArticlesPage,
|
||||
addCardArticle,
|
||||
updateCardArticle,
|
||||
deleteCardArticle,
|
||||
} from '../../../common/api.uts';
|
||||
import { getCurrentLocale, getOmniText } from '../../../common/locale.uts';
|
||||
|
||||
export type ArticlesApi = {
|
||||
fetchPage: (query: OwnerPageQuery) => Promise<OwnerPageData<CardArticle>>;
|
||||
addArticle: (data: Partial<CardArticle>) => Promise<CardArticle>;
|
||||
updateArticle: (data: Partial<CardArticle> & { id: string | number }) => Promise<CardArticle>;
|
||||
deleteArticle: (id: string | number) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export const defaultArticlesApi: ArticlesApi = {
|
||||
fetchPage: (query: OwnerPageQuery) => fetchCardArticlesPage(query),
|
||||
addArticle: (data: Partial<CardArticle>) => addCardArticle(data),
|
||||
updateArticle: (data: Partial<CardArticle> & { id: string | number }) => updateCardArticle(data),
|
||||
deleteArticle: (id: string | number) => deleteCardArticle(id),
|
||||
};
|
||||
|
||||
export type ArticlesState = {
|
||||
cardId: string | number;
|
||||
list: CardArticle[];
|
||||
loading: boolean;
|
||||
empty: boolean;
|
||||
errorMessage: string;
|
||||
drawerVisible: boolean;
|
||||
editingArticle: Partial<CardArticle> | null;
|
||||
saving: boolean;
|
||||
};
|
||||
|
||||
export function createArticlesState(cardId: string | number): ArticlesState {
|
||||
return {
|
||||
cardId,
|
||||
list: [],
|
||||
loading: false,
|
||||
empty: false,
|
||||
errorMessage: '',
|
||||
drawerVisible: false,
|
||||
editingArticle: null,
|
||||
saving: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractArticleSummary(content?: string, maxLen: number = 120): string {
|
||||
if (content == null || content.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
const plain = content
|
||||
.replace(/<[^>]+>/g, '') // 剥离 HTML
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // 剥离 Markdown 图片
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // 提取 Markdown 链接文本
|
||||
.replace(/[#*`_~>-]/g, '') // 移除 Markdown 常用标记
|
||||
.replace(/\s+/g, ' ') // 压缩多余空格
|
||||
.trim();
|
||||
|
||||
return plain.length > maxLen ? plain.slice(0, maxLen) : plain;
|
||||
}
|
||||
|
||||
export function validateArticleForm(form: Partial<CardArticle>): string | null {
|
||||
const title = form.title != null ? form.title.trim() : '';
|
||||
if (title === '') {
|
||||
return '请输入文章或动态标题';
|
||||
}
|
||||
const content = form.content != null ? form.content.trim() : '';
|
||||
if (content === '') {
|
||||
return '请输入文章正文内容';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function loadArticles(
|
||||
state: ArticlesState,
|
||||
api: ArticlesApi = defaultArticlesApi
|
||||
): Promise<void> {
|
||||
state.loading = true;
|
||||
state.errorMessage = '';
|
||||
try {
|
||||
const res = await api.fetchPage({
|
||||
page: 1,
|
||||
size: 50,
|
||||
cardId: state.cardId,
|
||||
});
|
||||
const items = res.list != null ? res.list : [];
|
||||
state.list = items;
|
||||
state.empty = items.length === 0;
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err.message || '加载文章列表失败';
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveArticle(
|
||||
state: ArticlesState,
|
||||
form: Partial<CardArticle>,
|
||||
api: ArticlesApi = defaultArticlesApi
|
||||
): Promise<boolean> {
|
||||
const err = validateArticleForm(form);
|
||||
if (err !== null) {
|
||||
state.errorMessage = err;
|
||||
return false;
|
||||
}
|
||||
|
||||
state.saving = true;
|
||||
state.errorMessage = '';
|
||||
try {
|
||||
const summary = (form.summary != null && form.summary!.trim() !== '')
|
||||
? form.summary!.trim()
|
||||
: extractArticleSummary(form.content);
|
||||
|
||||
const payload: Partial<CardArticle> = {
|
||||
...form,
|
||||
cardId: state.cardId,
|
||||
title: form.title!.trim(),
|
||||
summary,
|
||||
content: form.content!.trim(),
|
||||
isPublished: form.isPublished !== undefined ? form.isPublished : true,
|
||||
};
|
||||
|
||||
if (form.id != null) {
|
||||
await api.updateArticle({
|
||||
...payload,
|
||||
id: form.id,
|
||||
});
|
||||
} else {
|
||||
await api.addArticle(payload);
|
||||
}
|
||||
|
||||
state.drawerVisible = false;
|
||||
state.editingArticle = null;
|
||||
await loadArticles(state, api);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err.message || '保存文章失败';
|
||||
return false;
|
||||
} finally {
|
||||
state.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleArticlePublish(
|
||||
state: ArticlesState,
|
||||
article: CardArticle,
|
||||
api: ArticlesApi = defaultArticlesApi
|
||||
): Promise<boolean> {
|
||||
if (article.id == null) {
|
||||
return false;
|
||||
}
|
||||
state.saving = true;
|
||||
state.errorMessage = '';
|
||||
try {
|
||||
const targetStatus = !article.isPublished;
|
||||
await api.updateArticle({
|
||||
id: article.id,
|
||||
cardId: article.cardId,
|
||||
isPublished: targetStatus,
|
||||
});
|
||||
article.isPublished = targetStatus;
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err.message || '切换发布状态失败';
|
||||
return false;
|
||||
} finally {
|
||||
state.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteArticle(
|
||||
state: ArticlesState,
|
||||
id: string | number,
|
||||
api: ArticlesApi = defaultArticlesApi
|
||||
): Promise<boolean> {
|
||||
state.saving = true;
|
||||
state.errorMessage = '';
|
||||
try {
|
||||
const ok = await api.deleteArticle(id);
|
||||
if (ok) {
|
||||
await loadArticles(state, api);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err.message || '删除文章失败';
|
||||
return false;
|
||||
} finally {
|
||||
state.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
export type ArticlesCopyKey =
|
||||
| 'title'
|
||||
| 'subtitle'
|
||||
| 'publishBtn'
|
||||
| 'editBtn'
|
||||
| 'deleteBtn'
|
||||
| 'emptyTitle'
|
||||
| 'emptyDesc'
|
||||
| 'statusPublished'
|
||||
| 'statusDraft'
|
||||
| 'fieldTitle'
|
||||
| 'fieldSummary'
|
||||
| 'fieldCover'
|
||||
| 'fieldContent'
|
||||
| 'fieldPublish'
|
||||
| 'saveBtn'
|
||||
| 'cancelBtn';
|
||||
|
||||
export function resolveArticlesText(key: ArticlesCopyKey): string {
|
||||
const locale = getCurrentLocale();
|
||||
const zh = locale === 'zh-CN';
|
||||
|
||||
switch (key) {
|
||||
case 'title':
|
||||
return zh ? '文章与商务动态' : 'Insights & Articles';
|
||||
case 'subtitle':
|
||||
return zh ? '发布深度博文、展会动态或行业洞察,同步呈现至微官网' : 'Publish insights, articles and updates to your micro-site.';
|
||||
case 'publishBtn':
|
||||
return zh ? '发布新动态' : 'New Article';
|
||||
case 'editBtn':
|
||||
return zh ? '编辑' : 'Edit';
|
||||
case 'deleteBtn':
|
||||
return zh ? '删除' : 'Delete';
|
||||
case 'emptyTitle':
|
||||
return zh ? '暂无文章动态' : 'No Articles Yet';
|
||||
case 'emptyDesc':
|
||||
return zh ? '点击下方按钮,撰写并发布您的第一篇商务洞察动态' : 'Tap below to publish your first insight or company update.';
|
||||
case 'statusPublished':
|
||||
return zh ? '已发布' : 'Published';
|
||||
case 'statusDraft':
|
||||
return zh ? '草稿' : 'Draft';
|
||||
case 'fieldTitle':
|
||||
return zh ? '文章标题' : 'Title';
|
||||
case 'fieldSummary':
|
||||
return zh ? '摘要导读 (选填)' : 'Summary';
|
||||
case 'fieldCover':
|
||||
return zh ? '封面图片' : 'Cover Image';
|
||||
case 'fieldContent':
|
||||
return zh ? '文章正文 (支持富文本与 Markdown)' : 'Content';
|
||||
case 'fieldPublish':
|
||||
return zh ? '公开发布至微官网' : 'Publish to Micro-site';
|
||||
case 'saveBtn':
|
||||
return zh ? '确认发布' : 'Publish';
|
||||
case 'cancelBtn':
|
||||
return zh ? '取消' : 'Cancel';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - 产品相册与标杆案例移动端业务逻辑驱动层
|
||||
* 严格遵循 uts 强类型契约与 AGENTS.md 规范
|
||||
*/
|
||||
|
||||
import { CardCase, OwnerPageData, OwnerPageQuery } from '../../../common/types.uts';
|
||||
import {
|
||||
fetchCardCasesPage,
|
||||
addCardCase,
|
||||
updateCardCase,
|
||||
deleteCardCase,
|
||||
} from '../../../common/api.uts';
|
||||
import { getCurrentLocale, getOmniText } from '../../../common/locale.uts';
|
||||
|
||||
export const PRESET_CASE_METRICS: string[] = [
|
||||
'+320% ROI',
|
||||
'+150% 留资率',
|
||||
'45k+ 海外线索',
|
||||
'2M+ 全球曝光',
|
||||
'Top 1 细分类目',
|
||||
];
|
||||
|
||||
export type CasesApi = {
|
||||
fetchPage: (query: OwnerPageQuery) => Promise<OwnerPageData<CardCase>>;
|
||||
addCase: (data: Partial<CardCase>) => Promise<CardCase>;
|
||||
updateCase: (data: Partial<CardCase> & { id: string | number }) => Promise<CardCase>;
|
||||
deleteCase: (id: string | number) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export const defaultCasesApi: CasesApi = {
|
||||
fetchPage: (query: OwnerPageQuery) => fetchCardCasesPage(query),
|
||||
addCase: (data: Partial<CardCase>) => addCardCase(data),
|
||||
updateCase: (data: Partial<CardCase> & { id: string | number }) => updateCardCase(data),
|
||||
deleteCase: (id: string | number) => deleteCardCase(id),
|
||||
};
|
||||
|
||||
export type CasesState = {
|
||||
cardId: string | number;
|
||||
list: CardCase[];
|
||||
loading: boolean;
|
||||
empty: boolean;
|
||||
errorMessage: string;
|
||||
drawerVisible: boolean;
|
||||
editingCase: Partial<CardCase> | null;
|
||||
saving: boolean;
|
||||
};
|
||||
|
||||
export function createCasesState(cardId: string | number): CasesState {
|
||||
return {
|
||||
cardId,
|
||||
list: [],
|
||||
loading: false,
|
||||
empty: false,
|
||||
errorMessage: '',
|
||||
drawerVisible: false,
|
||||
editingCase: null,
|
||||
saving: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateCaseForm(form: Partial<CardCase>): string | null {
|
||||
const title = form.title != null ? form.title.trim() : '';
|
||||
if (title === '') {
|
||||
return '请输入案例或产品标题';
|
||||
}
|
||||
if (title.length > 150) {
|
||||
return '标题长度不能超过 150 个字符';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function loadCases(
|
||||
state: CasesState,
|
||||
api: CasesApi = defaultCasesApi
|
||||
): Promise<void> {
|
||||
state.loading = true;
|
||||
state.errorMessage = '';
|
||||
try {
|
||||
const res = await api.fetchPage({
|
||||
page: 1,
|
||||
size: 50,
|
||||
cardId: state.cardId,
|
||||
});
|
||||
const items = res.list != null ? res.list : [];
|
||||
state.list = items;
|
||||
state.empty = items.length === 0;
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err.message || '加载案例列表失败';
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCase(
|
||||
state: CasesState,
|
||||
form: Partial<CardCase>,
|
||||
api: CasesApi = defaultCasesApi
|
||||
): Promise<boolean> {
|
||||
const err = validateCaseForm(form);
|
||||
if (err !== null) {
|
||||
state.errorMessage = err;
|
||||
return false;
|
||||
}
|
||||
|
||||
state.saving = true;
|
||||
state.errorMessage = '';
|
||||
try {
|
||||
const payload: Partial<CardCase> = {
|
||||
...form,
|
||||
cardId: state.cardId,
|
||||
title: form.title!.trim(),
|
||||
};
|
||||
|
||||
if (form.id != null) {
|
||||
await api.updateCase({
|
||||
...payload,
|
||||
id: form.id,
|
||||
});
|
||||
} else {
|
||||
await api.addCase(payload);
|
||||
}
|
||||
|
||||
state.drawerVisible = false;
|
||||
state.editingCase = null;
|
||||
await loadCases(state, api);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err.message || '保存案例失败';
|
||||
return false;
|
||||
} finally {
|
||||
state.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCase(
|
||||
state: CasesState,
|
||||
id: string | number,
|
||||
api: CasesApi = defaultCasesApi
|
||||
): Promise<boolean> {
|
||||
state.saving = true;
|
||||
state.errorMessage = '';
|
||||
try {
|
||||
const ok = await api.deleteCase(id);
|
||||
if (ok) {
|
||||
await loadCases(state, api);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
state.errorMessage = err.message || '删除案例失败';
|
||||
return false;
|
||||
} finally {
|
||||
state.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
export type CasesCopyKey =
|
||||
| 'title'
|
||||
| 'subtitle'
|
||||
| 'addBtn'
|
||||
| 'editBtn'
|
||||
| 'deleteBtn'
|
||||
| 'emptyTitle'
|
||||
| 'emptyDesc'
|
||||
| 'fieldTitle'
|
||||
| 'fieldClient'
|
||||
| 'fieldDuration'
|
||||
| 'fieldMetrics'
|
||||
| 'fieldDesc'
|
||||
| 'fieldCover'
|
||||
| 'saveBtn'
|
||||
| 'cancelBtn';
|
||||
|
||||
export function resolveCasesText(key: CasesCopyKey): string {
|
||||
const locale = getCurrentLocale();
|
||||
const zh = locale === 'zh-CN';
|
||||
|
||||
switch (key) {
|
||||
case 'title':
|
||||
return zh ? '产品相册与标杆案例' : 'Portfolio & Case Studies';
|
||||
case 'subtitle':
|
||||
return zh ? '随时维护产品画廊与标杆案例,在微官网中向全球买家展现硬核实力' : 'Showcase your products and case studies on your micro-site.';
|
||||
case 'addBtn':
|
||||
return zh ? '新增产品案例' : 'Add Case Study';
|
||||
case 'editBtn':
|
||||
return zh ? '编辑' : 'Edit';
|
||||
case 'deleteBtn':
|
||||
return zh ? '删除' : 'Delete';
|
||||
case 'emptyTitle':
|
||||
return zh ? '暂无产品案例' : 'No Cases Yet';
|
||||
case 'emptyDesc':
|
||||
return zh ? '点击下方按钮,拍照或上传您的首个产品相册与案例' : 'Tap below to add your first product showcase or case study.';
|
||||
case 'fieldTitle':
|
||||
return zh ? '案例/产品名称' : 'Title';
|
||||
case 'fieldClient':
|
||||
return zh ? '合作客商/品牌 (选填)' : 'Client / Brand';
|
||||
case 'fieldDuration':
|
||||
return zh ? '项目周期 (选填)' : 'Timeline';
|
||||
case 'fieldMetrics':
|
||||
return zh ? '量化成效标签' : 'Key Metrics Tag';
|
||||
case 'fieldDesc':
|
||||
return zh ? '方案成果简述' : 'Overview';
|
||||
case 'fieldCover':
|
||||
return zh ? '产品封面与相册图片' : 'Cover & Photos';
|
||||
case 'saveBtn':
|
||||
return zh ? '保存案例' : 'Save Case';
|
||||
case 'cancelBtn':
|
||||
return zh ? '取消' : 'Cancel';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const MP_ROOT = path.resolve('m:/2026项目/CRMmp/mpuniapp');
|
||||
const articlesUtsPath = path.join(MP_ROOT, 'pages/owner/card/useCardArticles.uts');
|
||||
|
||||
test('1. useCardArticles.uts 文件必须存在且格式完整', () => {
|
||||
assert.ok(fs.existsSync(articlesUtsPath), '必须存在 useCardArticles.uts');
|
||||
const content = fs.readFileSync(articlesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes('export function createArticlesState'), '必须导出 createArticlesState');
|
||||
assert.ok(content.includes('export function extractArticleSummary'), '必须导出 extractArticleSummary');
|
||||
assert.ok(content.includes('export function validateArticleForm'), '必须导出 validateArticleForm');
|
||||
assert.ok(content.includes('export async function loadArticles'), '必须导出 loadArticles');
|
||||
assert.ok(content.includes('export async function saveArticle'), '必须导出 saveArticle');
|
||||
assert.ok(content.includes('export async function toggleArticlePublish'), '必须导出 toggleArticlePublish');
|
||||
assert.ok(content.includes('export async function deleteArticle'), '必须导出 deleteArticle');
|
||||
});
|
||||
|
||||
test('2. 摘要提取器必须支持剥离 Markdown 与 HTML 语法', () => {
|
||||
const content = fs.readFileSync(articlesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes('.replace(/<[^>]+>/g, \'\')'), '必须剥离 HTML 标签');
|
||||
assert.ok(content.includes('.replace(/!\\[.*?\\]\\(.*?\\)/g, \'\')'), '必须剥离 Markdown 图片');
|
||||
});
|
||||
|
||||
test('3. 文章表单校验逻辑实现规范', () => {
|
||||
const content = fs.readFileSync(articlesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes("title === ''"), '必须拦截空标题');
|
||||
assert.ok(content.includes("content === ''"), '必须拦截空正文');
|
||||
});
|
||||
|
||||
test('4. 必须通过 Cool-Admin 标准 CRUD 契约对接后端接口', () => {
|
||||
const content = fs.readFileSync(articlesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes('fetchCardArticlesPage'), '必须调用 fetchCardArticlesPage');
|
||||
assert.ok(content.includes('addCardArticle'), '必须调用 addCardArticle');
|
||||
assert.ok(content.includes('updateCardArticle'), '必须调用 updateCardArticle');
|
||||
assert.ok(content.includes('deleteCardArticle'), '必须调用 deleteCardArticle');
|
||||
});
|
||||
|
||||
test('5. 国际化与文案键完整覆盖', () => {
|
||||
const content = fs.readFileSync(articlesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes('resolveArticlesText'), '必须实现文案解析');
|
||||
assert.ok(content.includes('Insights & Articles'), '必须支持英文标题');
|
||||
assert.ok(content.includes('文章与商务动态'), '必须支持中文标题');
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const MP_ROOT = path.resolve('m:/2026项目/CRMmp/mpuniapp');
|
||||
const casesUtsPath = path.join(MP_ROOT, 'pages/owner/card/useCardCases.uts');
|
||||
|
||||
test('1. useCardCases.uts 文件必须存在且格式完整', () => {
|
||||
assert.ok(fs.existsSync(casesUtsPath), '必须存在 useCardCases.uts');
|
||||
const content = fs.readFileSync(casesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes('export function createCasesState'), '必须导出 createCasesState');
|
||||
assert.ok(content.includes('export function validateCaseForm'), '必须导出 validateCaseForm');
|
||||
assert.ok(content.includes('export async function loadCases'), '必须导出 loadCases');
|
||||
assert.ok(content.includes('export async function saveCase'), '必须导出 saveCase');
|
||||
assert.ok(content.includes('export async function deleteCase'), '必须导出 deleteCase');
|
||||
assert.ok(content.includes('export const PRESET_CASE_METRICS'), '必须导出 PRESET_CASE_METRICS');
|
||||
});
|
||||
|
||||
test('2. 预设指标标签完备且包含出海核心量化成效', () => {
|
||||
const content = fs.readFileSync(casesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes('+320% ROI'), '必须包含 +320% ROI');
|
||||
assert.ok(content.includes('+150% 留资率'), '必须包含 +150% 留资率');
|
||||
assert.ok(content.includes('45k+ 海外线索'), '必须包含 45k+ 海外线索');
|
||||
assert.ok(content.includes('2M+ 全球曝光'), '必须包含 2M+ 全球曝光');
|
||||
assert.ok(content.includes('Top 1 细分类目'), '必须包含 Top 1 细分类目');
|
||||
});
|
||||
|
||||
test('3. 案例表单校验逻辑实现规范', () => {
|
||||
const content = fs.readFileSync(casesUtsPath, 'utf-8');
|
||||
// 必须对标题非空和超长进行拦截
|
||||
assert.ok(content.includes("title === ''"), '必须拦截空标题');
|
||||
assert.ok(content.includes('title.length > 150'), '必须拦截超过 150 字符的标题');
|
||||
});
|
||||
|
||||
test('4. 必须通过 Cool-Admin 标准 CRUD 契约对接后端接口', () => {
|
||||
const content = fs.readFileSync(casesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes('fetchCardCasesPage'), '必须调用 fetchCardCasesPage');
|
||||
assert.ok(content.includes('addCardCase'), '必须调用 addCardCase');
|
||||
assert.ok(content.includes('updateCardCase'), '必须调用 updateCardCase');
|
||||
assert.ok(content.includes('deleteCardCase'), '必须调用 deleteCardCase');
|
||||
});
|
||||
|
||||
test('5. 国际化与文案键完整覆盖', () => {
|
||||
const content = fs.readFileSync(casesUtsPath, 'utf-8');
|
||||
assert.ok(content.includes('resolveCasesText'), '必须实现文案解析');
|
||||
assert.ok(content.includes('Portfolio & Case Studies'), '必须支持英文标题');
|
||||
assert.ok(content.includes('产品相册与标杆案例'), '必须支持中文标题');
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const MP_ROOT = path.resolve('m:/2026项目/CRMmp/mpuniapp');
|
||||
const casesPath = path.join(MP_ROOT, 'pages/owner/card/cases.uvue');
|
||||
const articlesPath = path.join(MP_ROOT, 'pages/owner/card/articles.uvue');
|
||||
|
||||
test('1. cases.uvue 和 articles.uvue 文件必须存在', () => {
|
||||
assert.ok(fs.existsSync(casesPath), '必须存在 cases.uvue');
|
||||
assert.ok(fs.existsSync(articlesPath), '必须存在 articles.uvue');
|
||||
});
|
||||
|
||||
test('2. 零 Emoji 违规审查 (严禁使用 Emoji 作为功能图标或排版内容)', () => {
|
||||
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
|
||||
const casesContent = fs.readFileSync(casesPath, 'utf-8');
|
||||
const articlesContent = fs.readFileSync(articlesPath, 'utf-8');
|
||||
|
||||
assert.equal(emojiRegex.test(casesContent), false, 'cases.uvue 严禁含有 Emoji');
|
||||
assert.equal(emojiRegex.test(articlesContent), false, 'articles.uvue 严禁含有 Emoji');
|
||||
});
|
||||
|
||||
test('3. 底部悬浮主 CTA 栏适配移动端视口安全区 (env(safe-area-inset-bottom))', () => {
|
||||
const casesContent = fs.readFileSync(casesPath, 'utf-8');
|
||||
const articlesContent = fs.readFileSync(articlesPath, 'utf-8');
|
||||
|
||||
assert.ok(
|
||||
casesContent.includes('env(safe-area-inset-bottom)'),
|
||||
'cases.uvue 必须适配 safe-area-inset-bottom'
|
||||
);
|
||||
assert.ok(
|
||||
articlesContent.includes('env(safe-area-inset-bottom)'),
|
||||
'articles.uvue 必须适配 safe-area-inset-bottom'
|
||||
);
|
||||
});
|
||||
|
||||
test('4. 表单输入框与多行文本框符合移动端 16px 防缩放规范', () => {
|
||||
const casesContent = fs.readFileSync(casesPath, 'utf-8');
|
||||
const articlesContent = fs.readFileSync(articlesPath, 'utf-8');
|
||||
|
||||
assert.ok(casesContent.includes('font-size: 16px;'), 'cases.uvue 输入框必须使用 16px');
|
||||
assert.ok(articlesContent.includes('font-size: 16px;'), 'articles.uvue 输入框必须使用 16px');
|
||||
});
|
||||
|
||||
test('5. 核心操作按钮触控热区保证 (min-height >= 44px 或 height >= 44px)', () => {
|
||||
const casesContent = fs.readFileSync(casesPath, 'utf-8');
|
||||
const articlesContent = fs.readFileSync(articlesPath, 'utf-8');
|
||||
|
||||
assert.ok(casesContent.includes('min-height: 44px;'), 'cases.uvue 按钮必须保证触控高度 >= 44px');
|
||||
assert.ok(articlesContent.includes('min-height: 44px;'), 'articles.uvue 按钮必须保证触控高度 >= 44px');
|
||||
});
|
||||
|
||||
test('6. 必须复用 components/image-uploader/image-uploader.uvue', () => {
|
||||
const casesContent = fs.readFileSync(casesPath, 'utf-8');
|
||||
const articlesContent = fs.readFileSync(articlesPath, 'utf-8');
|
||||
|
||||
assert.ok(
|
||||
casesContent.includes("import ImageUploader from '../../../components/image-uploader/image-uploader.uvue'"),
|
||||
'cases.uvue 必须复用 image-uploader 组件'
|
||||
);
|
||||
assert.ok(
|
||||
articlesContent.includes("import ImageUploader from '../../../components/image-uploader/image-uploader.uvue'"),
|
||||
'articles.uvue 必须复用 image-uploader 组件'
|
||||
);
|
||||
});
|
||||
|
||||
test('7. pages.json 中必须注册 cases 与 articles 路由', () => {
|
||||
const pagesJsonPath = path.join(MP_ROOT, 'pages.json');
|
||||
const pagesJson = JSON.parse(fs.readFileSync(pagesJsonPath, 'utf-8'));
|
||||
const routes = pagesJson.pages.map((p: any) => p.path);
|
||||
|
||||
assert.ok(routes.includes('pages/owner/card/cases'), 'pages.json 必须注册 cases 页面');
|
||||
assert.ok(routes.includes('pages/owner/card/articles'), 'pages.json 必须注册 articles 页面');
|
||||
});
|
||||
|
||||
test('8. edit.uvue 中必须包含 cases 与 articles 的 Bento 导航入口', () => {
|
||||
const editPath = path.join(MP_ROOT, 'pages/owner/card/edit.uvue');
|
||||
const editContent = fs.readFileSync(editPath, 'utf-8');
|
||||
|
||||
assert.ok(editContent.includes('handleGoCases'), 'edit.uvue 必须提供 handleGoCases');
|
||||
assert.ok(editContent.includes('handleGoArticles'), 'edit.uvue 必须提供 handleGoArticles');
|
||||
assert.ok(editContent.includes('pages/owner/card/cases'), 'edit.uvue 必须跳转至 cases 页面');
|
||||
assert.ok(editContent.includes('pages/owner/card/articles'), 'edit.uvue 必须跳转至 articles 页面');
|
||||
});
|
||||
|
||||
test('9. index.uvue 中必须提供快捷管理入口', () => {
|
||||
const indexPath = path.join(MP_ROOT, 'pages/owner/card/index.uvue');
|
||||
const indexContent = fs.readFileSync(indexPath, 'utf-8');
|
||||
|
||||
assert.ok(indexContent.includes('handleOpenCases'), 'index.uvue 必须提供 handleOpenCases');
|
||||
assert.ok(indexContent.includes('handleOpenArticles'), 'index.uvue 必须提供 handleOpenArticles');
|
||||
});
|
||||
@@ -17,6 +17,8 @@ const ALL_PAGES = [
|
||||
'pages/owner/card/index.uvue',
|
||||
'pages/owner/card/edit.uvue',
|
||||
'pages/owner/card/qrcode.uvue',
|
||||
'pages/owner/card/cases.uvue',
|
||||
'pages/owner/card/articles.uvue',
|
||||
'pages/owner/radar.uvue',
|
||||
'pages/owner/leads.uvue',
|
||||
'pages/owner/leads-detail.uvue',
|
||||
@@ -47,6 +49,8 @@ const FORM_PAGES = [
|
||||
'pages/portal/exchange.uvue',
|
||||
'pages/common/login.uvue',
|
||||
'pages/owner/card/edit.uvue',
|
||||
'pages/owner/card/cases.uvue',
|
||||
'pages/owner/card/articles.uvue',
|
||||
'pages/owner/leads-detail.uvue',
|
||||
'pages/owner/nfc/scan.uvue',
|
||||
'pages/owner/nfc/mode.uvue',
|
||||
@@ -61,6 +65,8 @@ const BOTTOM_CTA_PAGES = [
|
||||
'pages/common/login.uvue',
|
||||
'pages/owner/card/edit.uvue',
|
||||
'pages/owner/card/qrcode.uvue',
|
||||
'pages/owner/card/cases.uvue',
|
||||
'pages/owner/card/articles.uvue',
|
||||
'pages/owner/leads-detail.uvue',
|
||||
'pages/owner/nfc/scan.uvue',
|
||||
'pages/owner/nfc/mode.uvue',
|
||||
|
||||
在新工单中引用
屏蔽一个用户