完善C端功能、通知明细及接口文档

This commit is contained in:
richjohnrambo
2026-10-13 01:30:21 +08:00
parent 6d6b6a0b59
commit 32c6238751
954 changed files with 793 additions and 99 deletions
+177 -1
View File
@@ -1038,7 +1038,6 @@ body {
margin: 7px 0 14px;
color: var(--m-muted);
}
.m-product-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2388,6 +2387,173 @@ body {
color: #ff681c;
}
.m-notices-page {
padding-inline: 0;
background: #f7f7f7;
}
.m-notices-page .m-page-header {
padding-inline: 20px;
background: #fff;
}
.m-notices-list {
display: grid;
gap: 10px;
padding: 12px 14px 0;
}
.m-notices-item {
padding: 16px;
border: 1px solid #eeeeec;
border-radius: 8px;
background: #fff;
}
.m-notices-heading {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
align-items: center;
gap: 11px;
}
.m-notices-icon {
width: 42px;
height: 42px;
border-radius: 50%;
display: grid;
place-items: center;
color: #fff;
background: linear-gradient(145deg, #ffa840, #f2661c);
}
.m-notices-icon .n-icon {
font-size: 23px;
}
.m-notices-heading h2 {
margin: 0 0 5px;
overflow-wrap: anywhere;
font-size: 17px;
line-height: 1.35;
font-weight: 600;
}
.m-notices-tag {
display: inline-block;
padding: 2px 7px;
border-radius: 4px;
color: #7a531c;
background: #fff4df;
font-size: 12px;
line-height: 18px;
}
.m-notices-tag.is-activity {
color: #24704b;
background: #e9f7ef;
}
.m-notices-item > p {
margin: 13px 0 10px;
color: #3f4247;
font-size: 15px;
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.m-notices-item > time {
color: #999;
font-size: 13px;
}
.m-notices-footer {
min-height: 58px;
display: grid;
place-items: center;
padding: 8px 14px 20px;
color: #999;
font-size: 13px;
}
.m-notices-footer p {
margin: 0;
color: #b05b24;
}
.m-notices-footer button {
min-width: 110px;
min-height: 40px;
border: 0;
background: transparent;
color: #986024;
}
.m-notices-footer button:disabled {
opacity: 0.55;
}
.m-ledgers {
padding-inline: 0;
background: #f7f7f7;
}
.m-ledgers .m-page-header {
padding-inline: 20px;
background: #fff;
}
.m-ledger-tabs {
height: 58px;
padding: 8px 18px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
background: #fff;
border-top: 1px solid #f2f2f2;
}
.m-ledger-tabs button {
border: 0;
border-radius: 6px;
background: #f5f5f5;
color: #777;
font-size: 15px;
}
.m-ledger-tabs button.active {
color: #fff;
background: #986024;
}
.m-ledger-list {
padding: 12px 14px 24px;
display: grid;
gap: 10px;
}
.m-ledger-item {
padding: 15px 16px;
border-radius: 8px;
background: #fff;
box-shadow: 0 3px 14px rgba(73, 54, 31, 0.06);
}
.m-ledger-head {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.m-ledger-head strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 16px;
font-weight: 500;
}
.m-ledger-head b {
flex: 0 0 auto;
font-size: 18px;
font-weight: 600;
}
.m-ledger-head .is-income {
color: #1f9d68;
}
.m-ledger-head .is-expense {
color: #d55d43;
}
.m-ledger-item p {
margin: 9px 0 6px;
color: #444;
font-size: 15px;
}
.m-ledger-item time {
color: #9a9da3;
font-size: 13px;
}
@media (min-width: 751px) {
body {
background: #e9eaec;
@@ -2413,6 +2579,12 @@ body {
margin-inline: -14px;
padding-inline: 14px;
}
.m-product-detail {
margin-inline: -14px;
}
.m-plain-form {
margin-inline: -14px;
}
}
.m-section-title a {
@@ -3172,6 +3344,10 @@ body {
margin: 8px 0 0;
color: #777;
line-height: 1.6;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
}
.m-qualification-card > img {
position: absolute;
@@ -46,11 +46,21 @@
const loading = ref(true);
const error = ref('');
const fallback = '我们致力于通过高效的供应链和优质的服务为用户带来更多实惠与更美好的生活乐趣,让品质好物走进更多人的生活。';
const paragraphs = computed(() =>
String(content.value?.body_text || '').split(/\n+/).map((item) => item.trim()).filter(Boolean),
const bodyText = computed(() => String(content.value?.body_text || '').replace(/\s+/g, ' ').trim());
function sectionText(start: string, end: string) {
const body = bodyText.value;
const startAt = body.indexOf(start);
if (startAt < 0) return '';
const from = startAt + start.length;
const endAt = body.indexOf(end, from);
return body.slice(from, endAt < 0 ? undefined : endAt).trim().replace(/^[,。:“”\s]+/, '');
}
const operationText = computed(() =>
sectionText('多实惠,多乐趣', '新国潮,新消费') || content.value?.summary || fallback,
);
const developmentText = computed(() =>
sectionText('新国潮,新消费', '合作品牌') || content.value?.summary || fallback,
);
const operationText = computed(() => content.value?.summary || paragraphs.value[0] || fallback);
const developmentText = computed(() => paragraphs.value[1] || paragraphs.value[0] || fallback);
async function load() {
loading.value = true;
+1 -1
View File
@@ -348,7 +348,7 @@
loading.value = true;
error.value = '';
const key = String(route.params.key || '');
if (['recharge_records', 'wallet_ledgers', 'notices'].includes(key)) {
if (['recharge_records', 'wallet_ledgers'].includes(key)) {
loading.value = false;
return;
}
+2 -2
View File
@@ -128,13 +128,13 @@
label: '储入',
path: '/app/recharge',
icon: mobileAssets.shortcutRecharge,
enabled: home.value?.features?.recharge_enabled !== false,
enabled: home.value?.features?.recharge_enabled === true,
},
{
label: '取出',
path: '/app/profile/withdrawal',
icon: mobileAssets.shortcutWithdraw,
enabled: home.value?.features?.withdrawal_enabled !== false,
enabled: home.value?.features?.withdrawal_enabled === true,
},
{
label: '商城',
+105
View File
@@ -0,0 +1,105 @@
<template>
<section class="m-page m-page--white m-notices-page">
<PageHeader title="弹窗明细" />
<AsyncState
:loading="loading && !items.length"
:error="error && !items.length ? error : ''"
:empty="!items.length"
empty-text="暂无弹窗记录"
@retry="reload"
>
<div class="m-notices-list">
<article v-for="item in items" :key="item.id" class="m-notices-item">
<div class="m-notices-heading">
<span class="m-notices-icon" aria-hidden="true">
<n-icon :component="NotificationsOutline" />
</span>
<div>
<h2>{{ item.title || '通知' }}</h2>
<span :class="['m-notices-tag', { 'is-activity': item.source_type === 'operational_simulation' }]">
{{ sourceText(item.source_type) }}
</span>
</div>
</div>
<p>{{ item.content }}</p>
<time>{{ dateTime(item.created_at || item.start_at) }}</time>
</article>
</div>
<div class="m-notices-footer">
<p v-if="error">{{ error }}</p>
<button v-if="hasMore" type="button" :disabled="loading" @click="loadMore">
{{ loading ? '加载中...' : '加载更多' }}
</button>
<span v-else>没有更多了</span>
</div>
</AsyncState>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { NotificationsOutline } from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { dateTime, errorText, request } from '../api';
interface NoticeItem {
id: number;
title: string;
content: string;
source_type: 'real' | 'operational_simulation' | string;
start_at: string;
end_at: string;
created_at: string;
}
interface NoticePage {
items: NoticeItem[];
page: number;
page_size: number;
total: number;
}
const pageSize = 20;
const items = ref<NoticeItem[]>([]);
const page = ref(0);
const total = ref(0);
const loading = ref(false);
const error = ref('');
const hasMore = computed(() => items.value.length < total.value);
const sourceText = (value: string) =>
value === 'operational_simulation' ? '活动通知' : '系统通知';
async function load(targetPage: number) {
if (loading.value) return;
loading.value = true;
error.value = '';
try {
const data = await request<NoticePage>({
url: '/v1/notices',
params: { page: targetPage, page_size: pageSize },
});
items.value = targetPage === 1 ? data.items || [] : [...items.value, ...(data.items || [])];
page.value = data.page;
total.value = data.total;
} catch (err) {
error.value = errorText(err);
} finally {
loading.value = false;
}
}
function reload() {
items.value = [];
page.value = 0;
total.value = 0;
load(1);
}
function loadMore() {
load(page.value + 1);
}
onMounted(reload);
</script>
+2 -58
View File
@@ -99,46 +99,10 @@
><span>数量</span><b>×{{ detail.quantity || 1 }}</b></div
>
<div
><span>截止时间</span><b>{{ dateTime(detail.deadline_at) }}</b></div
><span>规格</span
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
>
</section>
<section v-if="detail.task?.description || detail.message" class="m-rule-box m-card"
><h2>任务说明</h2><p>{{ detail.task?.description || detail.message }}</p></section
>
<section v-if="detail.submission" class="m-detail-fields m-card">
<div
><span>提交状态</span
><b>{{ detail.submission.status_text || detail.submission.status }}</b></div
>
<div v-if="detail.submission.review_reason"
><span>处理说明</span><b>{{ detail.submission.review_reason }}</b></div
>
<div
><span>提交时间</span><b>{{ dateTime(detail.submission.submitted_at) }}</b></div
>
</section>
<form
v-if="detail.allowed_actions?.includes('submit')"
class="m-submit-form"
@submit.prevent="submitTask"
>
<h2>提交任务凭证</h2>
<textarea
v-model.trim="evidence"
maxlength="2000"
placeholder="请填写任务完成说明"
></textarea>
<small>{{ evidence.length }}/2000,附件上传接口开放后可补充图片或 PDF</small>
<button class="m-primary-btn" :disabled="submitting || !evidence">{{
submitting ? '提交中...' : '提交审核'
}}</button>
</form>
<router-link
v-if="detail.allowed_actions?.includes('contact_support')"
class="m-primary-btn m-btn-link"
to="/app/support"
>联系客服</router-link
>
</div>
</AsyncState>
</section>
@@ -160,8 +124,6 @@
const orders = ref<any[]>([]);
const detail = ref<any>();
const stats = ref<any>();
const evidence = ref('');
const submitting = ref(false);
let listController: AbortController | undefined;
const tabs = [
{ label: '全部', value: 'all' },
@@ -212,24 +174,6 @@
if (listController === controller) loading.value = false;
}
}
async function submitTask() {
if (!evidence.value || submitting.value) return;
submitting.value = true;
try {
await request({
url: `/v1/task-orders/${route.params.id}/submissions`,
method: 'POST',
data: { evidence_text: evidence.value, evidence_attachment_ids: [] },
});
window.$message?.success('提交成功,等待审核');
evidence.value = '';
await loadDetail();
} catch (err) {
window.$message?.error(errorText(err));
} finally {
submitting.value = false;
}
}
async function loadDetail() {
loading.value = true;
error.value = '';
+1 -1
View File
@@ -73,7 +73,7 @@
path: '/app/profile/records?tab=withdrawal',
icon: mobileAssets.menuWithdraw,
},
{ label: '账户明细', path: '/app/content/wallet_ledgers', icon: mobileAssets.menuLedger },
{ label: '账户明细', path: '/app/profile/ledgers', icon: mobileAssets.menuLedger },
{ label: '弹窗明细', path: '/app/content/notices', icon: mobileAssets.menuNotices },
];
async function load() {
+105
View File
@@ -0,0 +1,105 @@
<template>
<section class="m-page m-page--white m-ledgers">
<PageHeader title="账户明细" />
<div class="m-ledger-tabs" role="tablist" aria-label="收支类型">
<button
v-for="item in tabs"
:key="item.value"
:class="{ active: direction === item.value }"
@click="changeDirection(item.value)"
>
{{ item.label }}
</button>
</div>
<AsyncState
:loading="loading"
:error="error"
:empty="!items.length"
empty-text="暂无账户明细"
@retry="load"
>
<div class="m-ledger-list">
<article v-for="item in items" :key="item.id" class="m-ledger-item">
<div class="m-ledger-head">
<strong>{{ item.ledger_no }}</strong>
<b :class="item.direction === 'income' ? 'is-income' : 'is-expense'">
{{ item.direction === 'income' ? '+' : '-' }}{{ plainMoney(item.amount) }}
</b>
</div>
<p>{{ item.remark || entryText(item.entry_type) }}</p>
<time>{{ dateTimeSeconds(item.occurred_at) }}</time>
</article>
</div>
</AsyncState>
</section>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { errorText, request } from '../api';
type Direction = 'all' | 'income' | 'expense';
const tabs: Array<{ label: string; value: Direction }> = [
{ label: '全部', value: 'all' },
{ label: '收入', value: 'income' },
{ label: '支出', value: 'expense' },
];
const direction = ref<Direction>('all');
const loading = ref(true);
const error = ref('');
const items = ref<any[]>([]);
const entryLabels: Record<string, string> = {
task_commission: '任务佣金',
task_commission_reversal: '任务佣金冲正',
recharge_credit: '储入到账',
withdrawal_debit: '取出扣款',
manual_add: '人工调增',
manual_deduct: '人工调减',
available: '可用余额变动',
frozen: '冻结余额变动',
};
const plainMoney = (value: number) => (Number(value || 0) / 100).toFixed(2);
const entryText = (value: string) => entryLabels[value] || '账户余额变动';
function dateTimeSeconds(value?: string) {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--';
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.format(date)
.replaceAll('/', '-');
}
async function load() {
loading.value = true;
error.value = '';
try {
const data = await request<any>({
url: '/v1/wallet/ledgers',
params: { page: 1, page_size: 50, direction: direction.value },
});
items.value = data.items || [];
} catch (err) {
error.value = errorText(err);
items.value = [];
} finally {
loading.value = false;
}
}
function changeDirection(value: Direction) {
if (direction.value === value) return;
direction.value = value;
load();
}
onMounted(load);
</script>
+13
View File
@@ -124,6 +124,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('../mobile/views/FinanceRecordsView.vue'),
meta: { title: '记录' },
},
{
path: 'profile/ledgers',
alias: '/app/content/wallet_ledgers',
name: 'MobileWalletLedgers',
component: () => import('../mobile/views/WalletLedgersView.vue'),
meta: { title: '账户明细' },
},
{
path: 'profile/password',
name: 'MobileLoginPassword',
@@ -154,6 +161,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('../mobile/views/PlatformIntroView.vue'),
meta: { title: '平台介绍' },
},
{
path: 'content/notices',
name: 'MobileNotices',
component: () => import('../mobile/views/NoticesView.vue'),
meta: { title: '弹窗明细' },
},
{
path: 'content/:key',
name: 'MobileContent',