diff --git a/mpuniapp/common/api.uts b/mpuniapp/common/api.uts index 2eb9f5b..7ad7139 100644 --- a/mpuniapp/common/api.uts +++ b/mpuniapp/common/api.uts @@ -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> { + return httpPost>('/admin/card/cases/page', query); +} + +export function fetchCardCaseInfo(id: string | number): Promise { + return httpGet('/admin/card/cases/info', { id }); +} + +export function addCardCase(data: Partial): Promise { + return httpPost('/admin/card/cases/add', data); +} + +export function updateCardCase( + data: Partial & { id: string | number } +): Promise { + return httpPost('/admin/card/cases/update', data); +} + +export function deleteCardCase(id: string | number): Promise { + return httpPost('/admin/card/cases/delete', { ids: [id] }); +} + +export function fetchCardArticlesPage( + query: OwnerPageQuery +): Promise> { + return httpPost>('/admin/card/article/page', query); +} + +export function fetchCardArticleInfo(id: string | number): Promise { + return httpGet('/admin/card/article/info', { id }); +} + +export function addCardArticle(data: Partial): Promise { + return httpPost('/admin/card/article/add', data); +} + +export function updateCardArticle( + data: Partial & { id: string | number } +): Promise { + return httpPost('/admin/card/article/update', data); +} + +export function deleteCardArticle(id: string | number): Promise { + return httpPost('/admin/card/article/delete', { ids: [id] }); +} + export * from './types.uts'; export * from './request.uts'; diff --git a/mpuniapp/common/types.uts b/mpuniapp/common/types.uts index 438fe56..e9db8e4 100644 --- a/mpuniapp/common/types.uts +++ b/mpuniapp/common/types.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; + createTime?: Date | string; + updateTime?: Date | string; +} diff --git a/mpuniapp/pages.json b/mpuniapp/pages.json index 679156e..d91c175 100644 --- a/mpuniapp/pages.json +++ b/mpuniapp/pages.json @@ -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": { diff --git a/mpuniapp/pages/owner/card/articles.uvue b/mpuniapp/pages/owner/card/articles.uvue new file mode 100644 index 0000000..806e74e --- /dev/null +++ b/mpuniapp/pages/owner/card/articles.uvue @@ -0,0 +1,744 @@ + + + + + diff --git a/mpuniapp/pages/owner/card/cases.uvue b/mpuniapp/pages/owner/card/cases.uvue new file mode 100644 index 0000000..a48d31a --- /dev/null +++ b/mpuniapp/pages/owner/card/cases.uvue @@ -0,0 +1,785 @@ + + + + + diff --git a/mpuniapp/pages/owner/card/edit.uvue b/mpuniapp/pages/owner/card/edit.uvue index 4e38c56..b0d5b6a 100644 --- a/mpuniapp/pages/owner/card/edit.uvue +++ b/mpuniapp/pages/owner/card/edit.uvue @@ -1,5 +1,6 @@ diff --git a/mpuniapp/pages/owner/card/index.uvue b/mpuniapp/pages/owner/card/index.uvue index a832507..e8234ad 100644 --- a/mpuniapp/pages/owner/card/index.uvue +++ b/mpuniapp/pages/owner/card/index.uvue @@ -1,67 +1,155 @@ @@ -168,114 +505,415 @@ onShow(() => { display: flex; flex-direction: column; height: 100%; - background-color: var(--oc-surface, #F7F8FA); + min-height: 100vh; + background-color: var(--oc-bg, #F7F8FA); + position: relative; + overflow: hidden; } -.card-home-header { +.card-home-rtl { + direction: rtl; +} + +.showcase-section { display: flex; flex-direction: column; - padding-inline: var(--oc-space-16, 16px); - padding-block-start: var(--oc-space-16, 16px); - padding-block-end: var(--oc-space-12, 12px); - background-color: var(--oc-bg, #FFFFFF); - border-bottom: 1px solid var(--oc-border, #E3E7ED); + align-items: center; + padding-block-start: calc(8px + env(safe-area-inset-top, 0px)); + padding-inline: 16px; + padding-block-end: 24px; + background: linear-gradient(165deg, #0B63E5 0%, #0088FF 60%, #00A3FF 100%); + position: relative; } -.card-home-title { - font-size: 24px; +.showcase-aurora-glow { + position: absolute; + top: -30px; + left: 15%; + width: 70%; + height: 140px; + background: radial-gradient(circle, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0) 70%); + pointer-events: none; +} + +.showcase-topbar { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; + margin-block-end: 8px; + z-index: 2; +} + +.showcase-brand { + display: flex; + flex-direction: row; + align-items: center; +} + +.showcase-app-name { + font-size: 16px; + font-weight: 700; + color: #FFFFFF; + letter-spacing: 0.5px; +} + +.showcase-quota-badge { + font-size: 11px; + font-weight: 600; + color: #F04438; + background-color: #FFFFFF; + padding: 2px 8px; + border-radius: 9999px; + margin-inline-start: 8px; +} + +.showcase-action-refresh { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + padding: 4px 8px; +} + +.showcase-stand { + display: flex; + flex-direction: column; + align-items: center; + background-color: #FFFFFF; + border-radius: 20px; + padding: 12px 14px 10px; + box-shadow: 0 12px 36px rgba(11, 99, 229, 0.2), 0 2px 8px rgba(0, 0, 0, 0.04); + border: 1px solid rgba(255, 255, 255, 0.85); + z-index: 2; + transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.showcase-stand:active { + opacity: 0.92; +} + +.showcase-qr-box { + width: 140px; + height: 140px; + display: flex; + align-items: center; + justify-content: center; + background-color: #FFFFFF; +} + +.showcase-qr-img { + width: 140px; + height: 140px; +} + +.showcase-qr-rich { + width: 140px; + height: 140px; + display: flex; + align-items: center; + justify-content: center; +} + +.showcase-qr-skeleton { + width: 140px; + height: 140px; + display: flex; + align-items: center; + justify-content: center; + background-color: #F1F5F9; + border-radius: 12px; +} + +.showcase-qr-pulse { + width: 60px; + height: 60px; + border-radius: 8px; + background-color: #E2E8F0; +} + +.showcase-qr-placeholder { + width: 140px; + height: 140px; + display: flex; + align-items: center; + justify-content: center; + background-color: #F8FAFC; + border: 1px dashed #CBD5E1; + border-radius: 12px; + padding: 12px; +} + +.showcase-qr-tip { + font-size: 13px; + font-weight: 500; + color: #64748B; + line-height: 1.4; + text-align: center; +} + +.showcase-glow-guide { + display: flex; + align-items: center; + justify-content: center; + margin-top: 8px; + padding: 4px 12px; + background-color: #EBF5FF; + border-radius: 9999px; +} + +.showcase-guide-text { + font-size: 12px; + font-weight: 600; + color: #0088FF; + letter-spacing: 0.2px; +} + +.drawer-sheet { + flex: 1; + display: flex; + flex-direction: column; + background-color: #FFFFFF; + border-top-left-radius: 28px; + border-top-right-radius: 28px; + margin-top: -16px; + padding-inline: 16px; + padding-block-start: 10px; + padding-block-end: calc(84px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px)); + box-shadow: 0 -8px 30px rgba(16, 24, 40, 0.05); + position: relative; + z-index: 10; + overflow: hidden; +} + +.drawer-handle-bar { + display: flex; + justify-content: center; + padding-block-end: 8px; +} + +.drawer-handle { + width: 36px; + height: 4px; + background-color: #E2E8F0; + border-radius: 2px; +} + +.direct-link-bar { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + background-color: #F8FAFC; + border: 1px solid var(--oc-border, #E3E7ED); + border-radius: 12px; + padding: 6px 10px; + margin-block-end: 8px; +} + +.direct-link-info { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + margin-inline-end: 10px; +} + +.direct-link-label { + font-size: 10px; + font-weight: 600; + color: #64748B; + letter-spacing: 0.5px; +} + +.direct-link-url { + font-size: 13px; + font-weight: 500; + color: #101828; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.direct-link-copy-btn { + background-color: var(--oc-primary-soft, #EBF5FF); + border: 1px solid rgba(0, 136, 255, 0.2); + border-radius: 9999px; + padding: 4px 12px; + min-height: 28px; + display: flex; + align-items: center; + justify-content: center; + margin: 0; + line-height: 1; +} + +.direct-link-copy-text { + font-size: 12px; + font-weight: 700; + color: #0088FF; +} + +.value-prop-bar { + display: flex; + flex-direction: row; + align-items: center; + padding: 5px 10px; + background-color: #F0FDF4; + border: 1px solid #DCFCE7; + border-radius: 8px; + margin-block-end: 10px; +} + +.value-prop-tag { + font-size: 10px; + font-weight: 700; + color: #16A34A; + background-color: #DCFCE7; + padding: 1px 6px; + border-radius: 4px; + margin-inline-end: 6px; +} + +.value-prop-text { + font-size: 12px; + color: #15803D; + font-weight: 500; +} + +.drawer-card-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: baseline; + margin-block-end: 8px; +} + +.drawer-title { + font-size: 16px; font-weight: 700; color: var(--oc-text, #101828); - line-height: 1.25; - margin-bottom: var(--oc-space-8, 8px); } -.card-home-subtitle { - font-size: 14px; +.drawer-subtitle { + font-size: 12px; color: var(--oc-text-2, #667085); - line-height: 1.6; - margin-bottom: var(--oc-space-16, 16px); -} - -.card-home-search { - height: 44px; - padding-inline: 12px; - background-color: var(--oc-surface, #F7F8FA); - border: 1px solid var(--oc-border, #E3E7ED); - border-radius: var(--oc-radius-btn, 12px); - font-size: 16px; - color: var(--oc-text, #101828); } .card-home-skeleton { flex: 1; - padding-inline: var(--oc-space-16, 16px); - padding-block: var(--oc-space-16, 16px); + padding-block: 8px; } .skeleton-line { - height: 16px; - width: 40%; + height: 20px; + width: 50%; background-color: var(--oc-border, #E3E7ED); border-radius: 8px; - margin-bottom: var(--oc-space-12, 12px); + margin-block-end: 12px; } .skeleton-card { - height: 88px; + height: 76px; background-color: var(--oc-border, #E3E7ED); - border-radius: var(--oc-radius-card, 16px); - margin-bottom: var(--oc-space-12, 12px); + border-radius: 16px; + margin-block-end: 10px; } .card-home-feedback { flex: 1; - margin: 40px var(--oc-space-16, 16px); - padding: var(--oc-space-24, 24px); - background-color: var(--oc-bg, #FFFFFF); + margin-block: 16px; + padding: 20px 16px; + background-color: #FFFFFF; border: 1px solid var(--oc-border, #E3E7ED); - border-radius: var(--oc-radius-card, 16px); + border-radius: 16px; display: flex; flex-direction: column; align-items: center; + justify-content: center; } .feedback-title { font-size: 16px; font-weight: 600; color: var(--oc-text, #101828); - margin-bottom: var(--oc-space-8, 8px); + margin-block-end: 6px; } .feedback-desc { - font-size: 14px; + font-size: 13px; color: var(--oc-text-2, #667085); text-align: center; - line-height: 1.6; + line-height: 1.5; + margin-block-end: 12px; +} + +.feedback-retry-btn { + height: 36px; + padding: 0 16px; + background-color: var(--oc-primary-soft, #EBF5FF); + border-radius: 9999px; + border: 1px solid rgba(0, 136, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; +} + +.feedback-retry-text { + font-size: 13px; + font-weight: 600; + color: #0088FF; } .card-home-list { flex: 1; - padding-inline: var(--oc-space-16, 16px); - padding-block-start: var(--oc-space-12, 12px); - padding-block-end: calc(88px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px)); + min-height: 120px; } .card-row { - min-height: 88px; - padding: var(--oc-space-16, 16px); - margin-bottom: var(--oc-space-12, 12px); - background-color: var(--oc-bg, #FFFFFF); + min-height: 76px; + padding: 12px 14px; + margin-block-end: 8px; + background-color: #FFFFFF; border: 1px solid var(--oc-border, #E3E7ED); - border-radius: var(--oc-radius-card, 16px); + border-radius: 16px; + box-shadow: 0 1px 3px rgba(16, 24, 40, 0.04); display: flex; flex-direction: row; justify-content: space-between; align-items: center; - transition: transform 150ms cubic-bezier(0.2, 0.8, 0.2, 1); + position: relative; + transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1); } .card-row:active { - transform: scale(0.99); + opacity: 0.88; +} + +.card-row-active { + border: 1.5px solid #0088FF; + background-color: #F8FBFF; + box-shadow: 0 4px 16px rgba(0, 136, 255, 0.08); +} + +.card-active-indicator { + position: absolute; + left: 0; + top: 12px; + bottom: 12px; + width: 4px; + background-color: #0088FF; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} + +.card-home-rtl .card-active-indicator { + left: auto; + right: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; } .card-main { @@ -285,24 +923,53 @@ onShow(() => { padding-inline-end: 12px; } +.card-name-row { + display: flex; + flex-direction: row; + align-items: center; + margin-block-end: 2px; +} + .card-name { - font-size: 16px; - font-weight: 600; + font-size: 15px; + font-weight: 700; color: var(--oc-text, #101828); - line-height: 1.25; - margin-bottom: 4px; + line-height: 1.3; + margin-inline-end: 6px; +} + +.card-status-badge { + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 6px; +} + +.status-published { + color: #0088FF; + background-color: #EBF5FF; +} + +.status-draft { + color: #64748B; + background-color: #F1F5F9; +} + +.status-archived { + color: #94A3B8; + background-color: #F1F5F9; } .card-title { - font-size: 14px; + font-size: 13px; color: var(--oc-text-2, #667085); - line-height: 1.6; + line-height: 1.4; } .card-company { - font-size: 14px; + font-size: 13px; color: var(--oc-text-2, #667085); - line-height: 1.6; + line-height: 1.4; } .card-meta { @@ -311,63 +978,126 @@ onShow(() => { align-items: flex-end; } -.card-status { - font-size: 12px; - font-weight: 600; - color: var(--oc-primary, #0B5CFF); - margin-bottom: 8px; +.card-home-rtl .card-meta { + align-items: flex-start; } -.card-poster { - min-height: 44px; - min-width: 44px; - font-size: 12px; +.card-views-wrap { + display: flex; + flex-direction: row; + align-items: center; + margin-block-end: 4px; +} + +.card-views-label { + font-size: 11px; + color: var(--oc-text-2, #667085); + margin-inline-end: 4px; +} + +.card-views { + font-size: 11px; font-weight: 600; - color: var(--oc-primary, #0B5CFF); + color: var(--oc-text-2, #667085); +} + +.card-actions { + display: flex; + flex-direction: row; + align-items: center; +} + +.card-cases-btn, +.card-articles-btn { + font-size: 13px; + font-weight: 600; + color: #0088FF; + padding: 6px 8px; + min-height: 36px; display: flex; align-items: center; } +.card-preview-btn { + font-size: 13px; + font-weight: 600; + color: #0088FF; + padding: 6px 8px; + min-height: 36px; + display: flex; + align-items: center; +} + +.card-edit { + font-size: 13px; + font-weight: 600; + color: var(--oc-text, #101828); + padding: 6px 8px; + min-height: 44px; + min-width: 44px; + display: flex; + align-items: center; + justify-content: flex-end; +} + +.card-home-rtl .card-edit { + justify-content: flex-start; +} + .card-home-cta-bar { position: fixed; left: 0; right: 0; bottom: var(--window-bottom, 0px); - padding: 12px var(--oc-space-16, 16px) calc(12px + env(safe-area-inset-bottom, 0px)) var(--oc-space-16, 16px); - background-color: var(--oc-bg, #FFFFFF); + padding: 10px 16px calc(12px + env(safe-area-inset-bottom, 0px)); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.85) 0%, #FFFFFF 100%); border-top: 1px solid var(--oc-border, #E3E7ED); box-sizing: border-box; z-index: 100; } +.tabbar-inner { + display: flex; + width: 100%; +} + .primary-cta-btn { height: 48px; min-height: 44px; - background-color: var(--oc-primary, #0B5CFF); - border-radius: var(--oc-radius-btn, 12px); + background: linear-gradient(135deg, #00A3FF 0%, #0B5CFF 100%); + border-radius: 9999px; display: flex; align-items: center; justify-content: center; width: 100%; border-width: 0; + box-shadow: 0 8px 20px rgba(0, 136, 255, 0.25); transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1); } .primary-cta-btn:active { - opacity: 0.85; + opacity: 0.88; } .primary-cta-btn-disabled { - background-color: var(--oc-border, #E3E7ED); + background: var(--oc-border, #E3E7ED); + box-shadow: none; } .primary-cta-btn-text { - color: var(--oc-bg, #FFFFFF); - font-size: 15px; + color: #FFFFFF; + font-size: 16px; font-weight: 600; } .primary-cta-btn-disabled .primary-cta-btn-text { color: var(--oc-text-2, #667085); } + +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + } +} diff --git a/mpuniapp/pages/owner/card/useCardArticles.uts b/mpuniapp/pages/owner/card/useCardArticles.uts new file mode 100644 index 0000000..d32971d --- /dev/null +++ b/mpuniapp/pages/owner/card/useCardArticles.uts @@ -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>; + addArticle: (data: Partial) => Promise; + updateArticle: (data: Partial & { id: string | number }) => Promise; + deleteArticle: (id: string | number) => Promise; +}; + +export const defaultArticlesApi: ArticlesApi = { + fetchPage: (query: OwnerPageQuery) => fetchCardArticlesPage(query), + addArticle: (data: Partial) => addCardArticle(data), + updateArticle: (data: Partial & { 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 | 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): 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 { + 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, + api: ArticlesApi = defaultArticlesApi +): Promise { + 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 = { + ...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 { + 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 { + 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 ''; + } +} diff --git a/mpuniapp/pages/owner/card/useCardCases.uts b/mpuniapp/pages/owner/card/useCardCases.uts new file mode 100644 index 0000000..a04b575 --- /dev/null +++ b/mpuniapp/pages/owner/card/useCardCases.uts @@ -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>; + addCase: (data: Partial) => Promise; + updateCase: (data: Partial & { id: string | number }) => Promise; + deleteCase: (id: string | number) => Promise; +}; + +export const defaultCasesApi: CasesApi = { + fetchPage: (query: OwnerPageQuery) => fetchCardCasesPage(query), + addCase: (data: Partial) => addCardCase(data), + updateCase: (data: Partial & { 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 | 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): 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 { + 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, + api: CasesApi = defaultCasesApi +): Promise { + const err = validateCaseForm(form); + if (err !== null) { + state.errorMessage = err; + return false; + } + + state.saving = true; + state.errorMessage = ''; + try { + const payload: Partial = { + ...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 { + 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 ''; + } +} diff --git a/mpuniapp/tests/card-articles.test.ts b/mpuniapp/tests/card-articles.test.ts new file mode 100644 index 0000000..3b1aef2 --- /dev/null +++ b/mpuniapp/tests/card-articles.test.ts @@ -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('文章与商务动态'), '必须支持中文标题'); +}); diff --git a/mpuniapp/tests/card-cases.test.ts b/mpuniapp/tests/card-cases.test.ts new file mode 100644 index 0000000..b6adbd7 --- /dev/null +++ b/mpuniapp/tests/card-cases.test.ts @@ -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('产品相册与标杆案例'), '必须支持中文标题'); +}); diff --git a/mpuniapp/tests/cases-articles-ui.test.ts b/mpuniapp/tests/cases-articles-ui.test.ts new file mode 100644 index 0000000..b94cb6e --- /dev/null +++ b/mpuniapp/tests/cases-articles-ui.test.ts @@ -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'); +}); diff --git a/mpuniapp/tests/ui-ux-mobile-pages.test.ts b/mpuniapp/tests/ui-ux-mobile-pages.test.ts index f504bed..802b3f7 100644 --- a/mpuniapp/tests/ui-ux-mobile-pages.test.ts +++ b/mpuniapp/tests/ui-ux-mobile-pages.test.ts @@ -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',