feat(mpuniapp): 支持在移动端App维护产品相册与文章动态

这个提交包含在:
wikiboss
2026-09-21 14:59:34 +08:00
父节点 15ad5ef90c
当前提交 10b9a6b263
共有 13 个文件被更改,包括 4104 次插入 和 365 次删除

查看文件

@@ -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">&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="例如: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>

查看文件

@@ -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">&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="例如:全球智能硬件出海交付方案"
/>
</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',