chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
.credits-breakdown {
display: flex;
flex-direction: row;
background: #ffffff;
border-radius: 32rpx;
padding: 48rpx 32rpx;
box-shadow: 0 8rpx 32rpx rgba(30, 109, 235, 0.04);
border: 1rpx solid rgba(30, 109, 235, 0.06);
.credits-column {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 48rpx;
}
.credits-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
width: 100%;
&.clickable:active {
opacity: 0.8;
}
.credits-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 10rpx;
}
.credits-label {
font-size: 26rpx;
color: #8c96a0;
font-weight: 500;
}
.credits-btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 4rpx 16rpx;
border-radius: 100rpx;
border: 1rpx solid transparent;
&.btn-blue {
background-color: rgba(30, 109, 235, 0.06);
border-color: rgba(30, 109, 235, 0.12);
}
&.btn-green {
background-color: rgba(0, 139, 112, 0.06);
border-color: rgba(0, 139, 112, 0.12);
}
.credits-btn-text {
font-size: 20rpx;
font-weight: 500;
&.text-blue {
color: #1e6deb;
}
&.text-green {
color: #008b70;
}
}
}
.credits-value {
font-size: 48rpx;
font-weight: 700;
color: #1d2129;
&.color-blue {
color: #1e6deb;
}
&.color-green {
color: #008b70;
}
&.color-orange {
color: #e28c00;
}
}
}
}

View File

@@ -0,0 +1,92 @@
<template>
<view class="credits-breakdown">
<!-- 左列 -->
<view class="credits-column">
<view class="credits-item clickable" @tap="handleDetail">
<view class="credits-header">
<text class="credits-label">{{
t("Mobile.MySubscriptions.totalCredits")
}}</text>
<view class="credits-btn btn-blue">
<text class="credits-btn-text text-blue">{{
t("Mobile.MySubscriptions.detail")
}}</text>
</view>
</view>
<text class="credits-value">{{
formatNumber(summary?.totalCredit ?? 0, 0)
}}</text>
</view>
<view class="credits-item clickable" @tap="handleAddPurchase">
<view class="credits-header">
<text class="credits-label">{{
t("Mobile.MySubscriptions.purchaseCredits")
}}</text>
<view class="credits-btn btn-green">
<text class="credits-btn-text text-green">{{
t("Mobile.MySubscriptions.addPurchase")
}}</text>
</view>
</view>
<text class="credits-value color-green">{{
formatNumber(summary?.purchaseCredit ?? 0, 0)
}}</text>
</view>
</view>
<!-- 右列 -->
<view class="credits-column">
<view class="credits-item">
<view class="credits-header">
<text class="credits-label">{{
t("Mobile.MySubscriptions.subscriptionCredits")
}}</text>
</view>
<text class="credits-value color-blue">{{
formatNumber(summary?.subscriptionCredit ?? 0, 0)
}}</text>
</view>
<view class="credits-item">
<view class="credits-header">
<text class="credits-label">{{
t("Mobile.MySubscriptions.activityCredits")
}}</text>
</view>
<text class="credits-value color-orange">{{
formatNumber(summary?.activityCredit ?? 0, 0)
}}</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type { CreditSummaryInfo } from "@/subpackages/types/interfaces/subscription";
import { useI18n } from "@/utils/i18n";
import { formatNumber } from "@/utils/system";
const { t } = useI18n();
interface Props {
summary: CreditSummaryInfo | null;
}
defineProps<Props>();
const emit = defineEmits(["add-purchase"]);
const handleAddPurchase = () => {
emit("add-purchase");
};
const handleDetail = () => {
uni.navigateTo({
url: "/subpackages/pages/credit-records/credit-records",
});
};
</script>
<style lang="scss" scoped>
@import "./credits-breakdown.scss";
</style>

View File

@@ -0,0 +1,266 @@
.plan-cards {
margin-top: 24rpx;
.section-title-container {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 36rpx;
margin-bottom: 24rpx;
.section-title-bar {
width: 6rpx;
height: 30rpx;
background: linear-gradient(180deg, #1e6deb 0%, #008b70 100%);
border-radius: 4rpx;
margin-right: 12rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #1d2129;
line-height: 32rpx;
}
}
.cards-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.plan-card {
background: #fff;
border-radius: 16rpx;
padding: 0;
border: 2rpx solid #e5e6eb;
position: relative;
overflow: hidden;
&.plan-current {
border: 2rpx solid #1e6deb;
background: rgba(30, 109, 235, 0.02);
}
.plan-badges {
position: absolute;
top: -2rpx;
right: -2rpx;
z-index: 10;
.badge {
height: 52rpx;
padding: 0 28rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
border-radius: 0 16rpx 0 24rpx;
.badge-text {
font-size: 24rpx;
font-weight: 600;
color: #fff;
line-height: 24rpx;
}
&.badge-active {
background: #1e6deb;
}
&.badge-hot {
background: linear-gradient(135deg, #ff9465 0%, #ff5252 100%);
}
}
}
.plan-card-body {
padding: 36rpx 32rpx 40rpx 32rpx;
display: flex;
flex-direction: column;
.plan-name {
font-size: 32rpx;
font-weight: 600;
color: #1d2129;
margin-bottom: 8rpx;
padding-right: 140rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plan-desc {
font-size: 24rpx;
color: #86909c;
margin-bottom: 16rpx;
line-height: 1.4;
padding-right: 140rpx;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.plan-price-row {
display: flex;
flex-direction: row;
align-items: baseline;
margin-bottom: 12rpx;
.plan-price-symbol {
font-size: 32rpx;
font-weight: 700;
color: #1e6deb;
margin-right: 4rpx;
}
.plan-price-value {
font-size: 56rpx;
font-weight: 800;
color: #1e6deb;
line-height: 56rpx;
}
.plan-period {
font-size: 26rpx;
color: #4e5969;
font-weight: 500;
margin-left: 8rpx;
}
}
.plan-credits-tag {
align-self: flex-start;
background: #e8f3ff;
border-radius: 8rpx;
padding: 6rpx 16rpx;
margin-bottom: 24rpx;
display: flex;
flex-direction: row;
align-items: center;
.plan-credits-text {
font-size: 24rpx;
font-weight: 600;
color: #1e6deb;
line-height: 24rpx;
}
}
.benefits {
margin-bottom: 24rpx;
.benefit-group {
display: flex;
flex-direction: column;
gap: 20rpx;
.benefit-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
.benefit-icon-container {
width: 32rpx;
height: 32rpx;
border-radius: 16rpx;
background-color: #f2f3f5;
display: flex;
align-items: center;
justify-content: center;
.benefit-icon-text {
font-size: 18rpx;
font-weight: bold;
line-height: 18rpx;
}
&.icon-check {
.benefit-icon-text {
color: #4e5969;
}
}
&.icon-cross {
.benefit-icon-text {
color: #86909c;
}
}
}
.benefit-text {
font-size: 26rpx;
color: #1d2129;
line-height: 36rpx;
}
}
}
}
.plan-action-area {
margin-bottom: 32rpx;
width: 100%;
.plan-btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 76rpx;
background: #1e6deb;
border-radius: 12rpx;
box-shadow: none;
box-sizing: border-box;
&:active {
transform: scale(0.98);
opacity: 0.9;
}
&.plan-btn-processing {
opacity: 0.6;
}
&.plan-btn-current {
background: #efebe6;
.plan-btn-text {
color: #5c564f;
}
}
&.plan-btn-disabled {
background: #f5f5f5;
box-shadow: none;
pointer-events: none;
.plan-btn-text {
color: #c0c4cc;
}
&:active {
transform: none;
opacity: 1;
}
}
.plan-btn-text {
font-size: 26rpx;
color: #fff;
font-weight: 600;
line-height: 26rpx;
}
}
}
.plan-divider {
margin: 8rpx 0 24rpx;
border-top: 2rpx dashed #e5e6eb;
}
}
}
}

View File

@@ -0,0 +1,178 @@
<template>
<view class="plan-cards">
<view class="section-title-container">
<view class="section-title-bar"></view>
<text class="section-title">{{
t("Mobile.MySubscriptions.planGrid")
}}</text>
</view>
<view class="cards-list">
<view
v-for="plan in plans"
:key="plan.id"
class="plan-card"
:class="{ 'plan-current': plan.id === currentPlanId }"
>
<!-- 标签 -->
<view class="plan-badges">
<view v-if="plan.id === currentPlanId" class="badge badge-active">
<text class="badge-text">{{
t("Mobile.MySubscriptions.currentPlan")
}}</text>
</view>
<view v-else-if="plan.isHot" class="badge badge-hot">
<text class="badge-text">{{
t("Mobile.MySubscriptions.statusHot")
}}</text>
</view>
</view>
<!-- 主体内容 -->
<view class="plan-card-body">
<!-- 套餐信息 -->
<text class="plan-name">{{ plan.name }}</text>
<text v-if="plan.description" class="plan-desc">{{
plan.description
}}</text>
<view class="plan-price-row">
<text class="plan-price-symbol">¥</text>
<text class="plan-price-value">{{
formatNumber(plan.price, 2)
}}</text>
<text class="plan-period">{{ getPeriodUnit(plan.period) }}</text>
</view>
<view class="plan-credits-tag">
<text class="plan-credits-text">
{{
`${formatNumber(plan.creditAmount, 0)} ${t("Mobile.MySubscriptions.creditUnit")}${t("Mobile.MySubscriptions.perMonth")}`
}}
</text>
</view>
<!-- 按钮(移入副标题及价格信息下方,与 PC 端保持一致) -->
<view class="plan-action-area">
<view
class="plan-btn"
:class="{
'plan-btn-processing': processingId === plan.id,
'plan-btn-current': plan.id === currentPlanId,
'plan-btn-disabled':
plan.price <= 0 && plan.id !== currentPlanId,
}"
@tap="handleAction(plan)"
>
<text class="plan-btn-text">{{ getButtonText(plan) }}</text>
</view>
</view>
<!-- 分割线(与 PC 端保持一致) -->
<view class="plan-divider"></view>
<!-- 权益列表 -->
<view class="benefits" v-if="getBaseBenefits(plan).length > 0">
<view class="benefit-group">
<view
v-for="(item, index) in getBaseBenefits(plan)"
:key="index"
class="benefit-item"
>
<view
class="benefit-icon-container"
:class="item.selected ? 'icon-check' : 'icon-cross'"
>
<text class="benefit-icon-text">{{
item.selected ? "✓" : "✗"
}}</text>
</view>
<text class="benefit-text">{{ item.description }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type {
SystemSubscriptionPlan,
SystemSubscriptionPlanGroup,
SystemSubscriptionPlanItem,
} from "@/subpackages/types/interfaces/subscription";
import { useI18n } from "@/utils/i18n";
import { getPeriodUnit } from "../../utils.uts";
import { formatDate, formatNumber } from "@/utils/system";
import { useSubscriptionPurchase } from "../../hooks/useSubscriptionPurchase";
const { t } = useI18n();
const { processingId, handlePaySubscription } = useSubscriptionPurchase();
interface Props {
plans: SystemSubscriptionPlan[];
currentPlanId: number | null;
currentEndTime: string | null;
currentPlanPrice?: number | null;
}
const props = withDefaults(defineProps<Props>(), {
currentPlanPrice: null,
});
const getActionVerb = (planPrice: number): string => {
if (props.currentPlanId == null) {
return t("Mobile.MySubscriptions.subscribeNow");
}
const currentPrice = props.currentPlanPrice ?? 0;
if (planPrice <= currentPrice) {
return t("Mobile.MySubscriptions.subscribeNow");
}
return t("Mobile.MySubscriptions.upgradeNow");
};
const getButtonText = (plan: SystemSubscriptionPlan): string => {
if (plan.id === props.currentPlanId) {
const currentPlanBtnText = t("Mobile.MySubscriptions.currentPlanButton");
const renewNowText = t("Mobile.MySubscriptions.renewNow");
return `${currentPlanBtnText}(${renewNowText})`;
}
const verbText = getActionVerb(plan.price);
let periodText = t("Mobile.MySubscriptions.continuousMonthly");
if (plan.period === 3) {
periodText = t("Mobile.MySubscriptions.continuousQuarterly");
} else if (plan.period === 12) {
periodText = t("Mobile.MySubscriptions.continuousYearly");
}
return `${verbText}${plan.name}${periodText}`;
};
const handleAction = async (plan: SystemSubscriptionPlan) => {
if (plan.price <= 0 && plan.id !== props.currentPlanId) return;
await handlePaySubscription(plan.id);
};
const getBaseBenefits = (
plan: SystemSubscriptionPlan,
): SystemSubscriptionPlanItem[] => {
const list = [] as SystemSubscriptionPlanItem[];
const groups = plan.itemGroups;
if (groups != null) {
for (let i = 0; i < groups.length; i++) {
const g = groups[i];
if (g.groupType === "BASE" && g.items != null) {
for (let j = 0; j < g.items.length; j++) {
list.push(g.items[j]);
}
}
}
}
return list;
};
</script>
<style lang="scss" scoped>
@import "./plan-cards.scss";
</style>

View File

@@ -0,0 +1,85 @@
.purchase-modal {
padding: 16rpx 0;
.purchase-subtitle {
font-size: 24rpx;
color: #999;
margin-bottom: 24rpx;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 60rpx 0;
.loading-text {
font-size: 28rpx;
color: #999;
}
}
.package-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.package-card {
background: #f9f9f9;
border-radius: 12rpx;
padding: 24rpx;
border: 2rpx solid transparent;
&:active {
background: #f0f0f0;
}
&.package-processing {
opacity: 0.6;
}
.package-header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8rpx;
margin-bottom: 12rpx;
.package-name {
font-size: 28rpx;
font-weight: 600;
color: #1d2129;
}
.package-remark {
margin-top: 4rpx;
.remark-text {
font-size: 24rpx;
color: #86909c;
line-height: 34rpx;
}
}
}
.package-footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
.package-credit {
font-size: 26rpx;
color: #1a6bff;
font-weight: 500;
}
.package-price {
font-size: 32rpx;
font-weight: 700;
color: #f59e0b;
}
}
}
}

View File

@@ -0,0 +1,92 @@
<template>
<modal-popup
ref="modalRef"
:title="t('Mobile.MySubscriptions.purchaseTitle')"
>
<view class="purchase-modal">
<text class="purchase-subtitle">{{
t("Mobile.MySubscriptions.purchaseSubtitle")
}}</text>
<view v-if="loading" class="loading-state">
<text class="loading-text">...</text>
</view>
<view v-else class="package-list">
<view
v-for="pkg in packages"
:key="pkg.id"
class="package-card"
:class="{ 'package-processing': processingId === pkg.id }"
@tap="handlePurchase(pkg)"
>
<view class="package-header">
<text class="package-name">{{ pkg.packageName }}</text>
<view v-if="pkg.remark" class="package-remark">
<text class="remark-text">{{ pkg.remark }}</text>
</view>
</view>
<view class="package-footer">
<text class="package-credit">{{
`${formatNumber(pkg.creditAmount, 0)} ${t("Mobile.MySubscriptions.creditPackageCredit")}`
}}</text>
<text class="package-price">{{
`¥ ${formatNumber(pkg.price, 2)}`
}}</text>
</view>
</view>
</view>
</view>
</modal-popup>
</template>
<script setup lang="uts">
import type { CreditPackageInfo } from "@/subpackages/types/interfaces/subscription";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { apiListCreditPackages } from "@/subpackages/servers/subscription";
import { useI18n } from "@/utils/i18n";
import { formatNumber } from "@/utils/system";
import ModalPopup from "@/components/modal-popup/modal-popup.uvue";
import { useSubscriptionPurchase } from "../../hooks/useSubscriptionPurchase";
const { t } = useI18n();
const { processingId, handlePayCredits } = useSubscriptionPurchase();
const modalRef = ref<any>(null);
const packages = ref<CreditPackageInfo[]>([]);
const loading = ref(false);
const open = async () => {
modalRef.value?.open();
await fetchPackages();
};
const close = () => {
modalRef.value?.close();
};
const fetchPackages = async () => {
loading.value = true;
try {
const res = await apiListCreditPackages();
if (res.code === SUCCESS_CODE && res.data) {
const data = res.data as any;
packages.value = Array.isArray(data) ? data : data.data || [];
}
} catch (e) {
// ignore
} finally {
loading.value = false;
}
};
const handlePurchase = async (pkg: CreditPackageInfo) => {
await handlePayCredits(pkg.id);
};
defineExpose({ open, close });
</script>
<style lang="scss" scoped>
@import "./purchase-modal.scss";
</style>

View File

@@ -0,0 +1,314 @@
<template>
<view class="subscribed-agents">
<view v-if="loading" class="loading-state">
<text class="loading-text">...</text>
</view>
<view v-else-if="list.length === 0" class="empty-wrapper">
<empty-state text="Mobile.MySubscriptions.empty" />
</view>
<view v-else class="agent-list">
<view v-for="item in list" :key="item.id" class="agent-card">
<!-- 头部 -->
<view class="agent-header">
<view
class="agent-icon"
:style="{ backgroundColor: item.icon ? 'transparent' : '#1e6deb' }"
>
<image
v-if="item.icon"
:src="item.icon"
class="agent-icon-img"
mode="aspectFill"
/>
<text v-else class="iconfont icon-Grid"></text>
</view>
<view class="agent-title-box">
<text class="agent-name">{{ item.bizName }}</text>
<text class="agent-desc" v-if="item.planName">{{
item.planName
}}</text>
</view>
</view>
<!-- 信息网格 -->
<view class="agent-grid">
<view class="grid-item">
<text class="grid-label">{{
t("Mobile.MySubscriptions.subAmount")
}}</text>
<view class="grid-value-row">
<text class="amount-val"
>¥{{ formatNumber(item.plan?.price ?? 0, 2) }}</text
>
<text class="amount-unit">{{ getPeriodUnit(item.period) }}</text>
</view>
</view>
<view class="grid-item" v-if="item.endTime">
<text class="grid-label">{{
t("Mobile.MySubscriptions.expireTime")
}}</text>
<text class="grid-value">{{
formatDate(item.endTime || "", "YYYY-MM-DD")
}}</text>
</view>
</view>
<!-- 底部 -->
<view class="agent-footer">
<view
class="status-text"
:class="isActive(item.status) ? 'status-active' : 'status-expired'"
>
<text class="icon-char">{{
isActive(item.status) ? "✓" : "✗"
}}</text>
<text class="text">{{ getStatusText(item.status) }}</text>
</view>
<view class="renew-btn" @tap="handleRenew(item)">
<text class="btn-text">{{
t("Mobile.MySubscriptions.renewNow")
}}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type { MySubscriptionItem } from "@/subpackages/types/interfaces/subscription";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { apiGetMySubscription } from "@/subpackages/servers/subscription";
import { useI18n } from "@/utils/i18n";
import { useSubscriptionPurchase } from "../../../hooks/useSubscriptionPurchase.uts";
import { getPeriodUnit, getStatusText, isActive } from "../../../utils.uts";
import EmptyState from "@/components/empty-state/empty-state.uvue";
import { formatDate, formatNumber } from "@/utils/system";
const { t } = useI18n();
const list = ref<MySubscriptionItem[]>([]);
const loading = ref(true);
const fetchData = async () => {
loading.value = true;
try {
const res = await apiGetMySubscription({ bizType: "AGENT" });
if (res.code === SUCCESS_CODE && res.data) {
list.value = res.data.subscriptions || [];
}
} catch (e) {
// ignore
} finally {
loading.value = false;
}
};
const { handlePaySubscription, processingId } = useSubscriptionPurchase();
const handleRenew = async (item: MySubscriptionItem) => {
await handlePaySubscription(item.planId);
};
onMounted(() => {
fetchData();
});
</script>
<style lang="scss" scoped>
.subscribed-agents {
min-height: 200rpx;
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 80rpx 0;
.loading-text {
font-size: 28rpx;
color: #999;
}
}
.empty-wrapper {
padding: 40rpx 0;
}
.agent-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.agent-card {
display: flex;
flex-direction: column;
background: #fff;
border-radius: 16rpx;
padding: 28rpx 28rpx 0 28rpx;
border: 2rpx solid #e5e6eb;
overflow: hidden;
.agent-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 24rpx;
margin-bottom: 40rpx;
.agent-icon {
width: 96rpx;
height: 96rpx;
border-radius: 20rpx;
background: #1e6deb;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 16rpx rgba(30, 109, 235, 0.15);
overflow: hidden;
.agent-icon-img {
width: 100%;
height: 100%;
border-radius: 20rpx;
}
.iconfont {
font-size: 48rpx;
color: #fff;
}
}
.agent-title-box {
display: flex;
flex-direction: column;
gap: 12rpx;
flex: 1;
overflow: hidden;
.agent-name {
font-size: 32rpx;
font-weight: 600;
color: #1d2129;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-desc {
font-size: 24rpx;
color: #8c96a0;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
}
.agent-grid {
display: flex;
flex-direction: row;
margin-bottom: 28rpx;
.grid-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
.grid-label {
font-size: 24rpx;
color: #8c96a0;
}
.grid-value {
font-size: 28rpx;
font-weight: 600;
color: #1d2129;
}
.grid-value-row {
display: flex;
flex-direction: row;
align-items: baseline;
.amount-val {
font-size: 28rpx;
font-weight: 600;
color: #5147ff;
}
.amount-unit {
font-size: 24rpx;
color: #8c96a0;
margin-left: 4rpx;
}
}
}
}
.agent-footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin: 0 -28rpx 0 -28rpx;
padding: 24rpx 28rpx;
background: #f8fafc;
border-top: 2rpx solid #e5e6eb;
.status-text {
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
.icon-char {
font-size: 26rpx;
}
.text {
font-size: 26rpx;
font-weight: 500;
}
&.status-active {
color: #00b42a;
}
&.status-expired {
color: #f53f3f;
}
}
.renew-btn {
display: flex;
align-items: center;
justify-content: center;
height: 64rpx;
background: #1e6deb;
padding: 0 36rpx;
border-radius: 12rpx;
.btn-text {
font-size: 26rpx;
color: #fff;
font-weight: 600;
}
&:active {
opacity: 0.8;
transform: scale(0.98);
}
}
}
}
}
</style>

View File

@@ -0,0 +1,31 @@
.subscribed-content {
margin-top: 24rpx;
.section-title-container {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 36rpx;
margin-bottom: 24rpx;
.section-title-bar {
width: 6rpx;
height: 30rpx;
background: linear-gradient(180deg, #1e6deb 0%, #008b70 100%);
border-radius: 4rpx;
margin-right: 12rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #1d2129;
line-height: 32rpx;
}
}
.tab-panel {
margin-top: 20rpx;
min-height: 400rpx;
}
}

View File

@@ -0,0 +1,46 @@
<template>
<view class="subscribed-content">
<view class="section-title-container">
<view class="section-title-bar"></view>
<text class="section-title">{{ t("Mobile.MySubscriptions.subscribedContent") }}</text>
</view>
<segmented-control
:options="tabOptions"
:default-index="0"
@change="handleTabChange"
/>
<view class="tab-panel">
<SubscribedAgents v-show="activeTab === 'agents'" />
<SubscribedSkills v-show="activeTab === 'skills'" />
<SubscribedCredits v-show="activeTab === 'credits'" />
</view>
</view>
</template>
<script setup lang="uts">
import { useI18n } from '@/utils/i18n';
import SegmentedControl from '@/components/segmented-control/segmented-control.uvue';
import SubscribedAgents from './subscribed-agents/subscribed-agents.uvue';
import SubscribedSkills from './subscribed-skills/subscribed-skills.uvue';
import SubscribedCredits from './subscribed-credits/subscribed-credits.uvue';
const { t } = useI18n();
const activeTab = ref<string>('agents');
const tabOptions = [
{ label: 'Mobile.MySubscriptions.tabAgents', value: 'agents' },
{ label: 'Mobile.MySubscriptions.tabSkills', value: 'skills' },
{ label: 'Mobile.MySubscriptions.tabCredits', value: 'credits' },
];
const handleTabChange = (value: string | number) => {
activeTab.value = value as string;
};
</script>
<style lang="scss" scoped>
@import './subscribed-content.scss';
</style>

View File

@@ -0,0 +1,283 @@
<template>
<view class="subscribed-credits">
<view v-if="loading" class="loading-state">
<text class="loading-text">...</text>
</view>
<view v-else-if="list.length === 0" class="empty-wrapper">
<empty-state text="Mobile.MySubscriptions.empty" />
</view>
<view v-else class="credit-list">
<view v-for="item in list" :key="item.id" class="credit-card">
<!-- 头部 -->
<view class="credit-header">
<text class="credit-name">{{ item.remark || item.batchNo }}</text>
<text class="credit-date">{{
formatDate(item.created || "", "YYYY-MM-DD")
}}</text>
</view>
<!-- 数据四宫格 -->
<view class="credit-grid">
<view class="grid-item">
<text class="grid-label">{{
t("Mobile.MySubscriptions.totalCredits")
}}</text>
<text class="grid-value"
>+{{ formatNumber(item.totalAmount, 0) }}</text
>
</view>
<view class="grid-item">
<text class="grid-label">{{
t("Mobile.MySubscriptions.usedAmount")
}}</text>
<text class="grid-value">{{
formatNumber(item.usedAmount, 0)
}}</text>
</view>
<view class="grid-item">
<text class="grid-label">{{
t("Mobile.MySubscriptions.expireTime")
}}</text>
<text class="grid-value">{{
item.expireTime
? formatDate(item.expireTime || "", "YYYY-MM-DD")
: "-"
}}</text>
</view>
<view class="grid-item">
<text class="grid-label">{{
t("Mobile.MySubscriptions.purchaseAmount")
}}</text>
<text
class="grid-value highlight-price"
>{{ item.extra?.price != null ? `¥ ${formatNumber(item.extra!.price, 2)}` : '-' }}</text
>
</view>
</view>
<!-- 底部 -->
<view class="credit-footer">
<text class="footer-remain">
{{
`${t("Mobile.MySubscriptions.remaining")}${formatNumber(item.remainAmount, 0)} ${t("Mobile.MySubscriptions.creditUnit")}`
}}
</text>
<view
v-if="getCreditStatus(item)"
class="status-tag"
:class="getCreditStatus(item)?.cls"
>
<text class="status-text">{{ getCreditStatus(item)?.text }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type { CreditBatchItem } from "@/subpackages/types/interfaces/subscription";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { apiGetCreditBatches } from "@/subpackages/servers/subscription";
import { useI18n } from "@/utils/i18n";
import EmptyState from "@/components/empty-state/empty-state.uvue";
import { formatDate, formatNumber } from "@/utils/system";
const { t } = useI18n();
const list = ref<CreditBatchItem[]>([]);
const loading = ref(true);
interface CreditStatus {
text: string;
cls: string;
}
const getCreditStatus = (item: CreditBatchItem): CreditStatus | null => {
if (item.expired) {
return {
text: t("Mobile.MySubscriptions.expired"),
cls: "status-expired",
};
}
if (item.remainAmount <= 0) {
return {
text: t("Mobile.MySubscriptions.fullyUsed"),
cls: "status-expired",
};
}
const ratio = item.totalAmount > 0 ? item.usedAmount / item.totalAmount : 0;
if (ratio >= 0.8) {
return {
text: t("Mobile.MySubscriptions.aboutToUseUp"),
cls: "status-warning",
};
}
return {
text: t("Mobile.MySubscriptions.normalUsage"),
cls: "status-active",
};
};
const fetchData = async () => {
loading.value = true;
try {
const res = await apiGetCreditBatches({ creditType: "PURCHASE" });
if (res.code === SUCCESS_CODE && res.data) {
list.value = Array.isArray(res.data) ? res.data : [];
}
} catch (e) {
// ignore
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchData();
});
</script>
<style lang="scss" scoped>
.subscribed-credits {
min-height: 200rpx;
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 80rpx 0;
.loading-text {
font-size: 28rpx;
color: #999;
}
}
.empty-wrapper {
padding: 40rpx 0;
}
.credit-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.credit-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
overflow: hidden;
border: 2rpx solid #e5e6eb;
.credit-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
.credit-name {
font-size: 28rpx;
font-weight: 600;
color: #333;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 16rpx;
}
.credit-date {
font-size: 22rpx;
color: #999;
flex-shrink: 0;
}
}
.credit-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 16rpx;
padding: 20rpx 0;
border-top: 2rpx solid #e5e6eb;
.grid-item {
width: calc(50% - 8rpx);
display: flex;
flex-direction: column;
gap: 8rpx;
.grid-label {
font-size: 22rpx;
color: #999;
}
.grid-value {
font-size: 28rpx;
font-weight: 600;
color: #333;
&.highlight-price {
color: #f59e0b;
}
}
}
}
.credit-footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin: 20rpx -24rpx -24rpx -24rpx;
padding: 24rpx 24rpx;
background: #f8fafc;
border-top: 2rpx solid #e5e6eb;
.footer-remain {
font-size: 24rpx;
color: #666;
}
.status-tag {
padding: 4rpx 16rpx;
border-radius: 6rpx;
.status-text {
font-size: 22rpx;
font-weight: 500;
}
&.status-active {
background: rgba(82, 196, 26, 0.1);
.status-text {
color: #52c41a;
}
}
&.status-warning {
background: rgba(245, 158, 11, 0.1);
.status-text {
color: #f59e0b;
}
}
&.status-expired {
background: rgba(255, 77, 79, 0.1);
.status-text {
color: #ff4d4f;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,296 @@
<template>
<view class="subscribed-skills">
<view v-if="loading" class="loading-state">
<text class="loading-text">...</text>
</view>
<view v-else-if="list.length === 0" class="empty-wrapper">
<empty-state text="Mobile.MySubscriptions.empty" />
</view>
<view v-else class="skill-list">
<view v-for="item in list" :key="item.id" class="skill-card">
<view class="skill-header">
<view class="skill-header-left">
<view class="skill-icon" :style="{ backgroundColor: item.icon ? 'transparent' : '#5147ff' }">
<image v-if="item.icon" :src="item.icon" class="skill-icon-img" mode="aspectFill" />
<text v-else class="iconfont icon-Grid"></text>
</view>
<view class="skill-title-box">
<text class="skill-name">{{ item.bizName }}</text>
<text class="skill-desc" v-if="item.planName">{{ item.planName }}</text>
</view>
</view>
<view class="skill-header-right">
<!-- 买断 -->
<view v-if="isForever(item.period)" class="tag tag-bought">
<text class="tag-text">{{
t("Mobile.MySubscriptions.boughtOut")
}}</text>
</view>
<!-- 订阅状态 -->
<view
v-else
class="status-tag"
:class="isActive(item.status) ? 'status-active' : 'status-expired'"
>
<text class="status-text">{{ getStatusText(item.status) }}</text>
</view>
</view>
</view>
<view class="skill-body">
<!-- 买断模式 -->
<view v-if="isForever(item.period)" class="skill-row">
<text class="skill-label">{{
t("Mobile.MySubscriptions.buyoutPriceLabel")
}}</text>
<text class="skill-value">{{
`¥ ${formatNumber(item.plan?.price ?? 0, 2)}`
}}</text>
</view>
<view v-if="isForever(item.period)" class="skill-row">
<text class="skill-label">{{
t("Mobile.MySubscriptions.permanentUse")
}}</text>
</view>
<!-- 订阅模式 -->
<view v-if="!isForever(item.period)" class="skill-row">
<text class="skill-label">{{
t("Mobile.MySubscriptions.subAmount")
}}</text>
<text class="skill-value">
{{
`¥ ${formatNumber(item.plan?.price ?? 0, 2)} ${getPeriodUnit(item.period)}`
}}
</text>
</view>
<view v-if="!isForever(item.period)" class="skill-row">
<text class="skill-label">{{
t("Mobile.MySubscriptions.expireTime")
}}</text>
<text class="skill-value">{{
item.endTime ? formatDate(item.endTime || "", "YYYY-MM-DD") : "-"
}}</text>
</view>
<view v-if="!isForever(item.period)" class="skill-row">
<text class="skill-label">{{ getPayTypeText(item.period) }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type { MySubscriptionItem } from "@/subpackages/types/interfaces/subscription";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { apiGetMySubscription } from "@/subpackages/servers/subscription";
import { useI18n } from "@/utils/i18n";
import {
getPeriodUnit,
getStatusText,
isActive,
isForever,
getPayTypeText,
} from "../../../utils.uts";
import EmptyState from "@/components/empty-state/empty-state.uvue";
import { formatDate, formatNumber } from "@/utils/system";
const { t } = useI18n();
const list = ref<MySubscriptionItem[]>([]);
const loading = ref(true);
const fetchData = async () => {
loading.value = true;
try {
const res = await apiGetMySubscription({ bizType: "SKILL" });
if (res.code === SUCCESS_CODE && res.data) {
list.value = res.data.subscriptions || [];
}
} catch (e) {
// ignore
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchData();
});
</script>
<style lang="scss" scoped>
.subscribed-skills {
min-height: 200rpx;
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 80rpx 0;
.loading-text {
font-size: 28rpx;
color: #999;
}
}
.empty-wrapper {
padding: 40rpx 0;
}
.skill-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skill-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
.skill-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
.skill-header-left {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
overflow: hidden;
margin-right: 12rpx;
.skill-icon {
width: 72rpx;
height: 72rpx;
border-radius: 12rpx;
background: #5147ff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.skill-icon-img {
width: 100%;
height: 100%;
border-radius: 12rpx;
}
.iconfont {
font-size: 36rpx;
color: #fff;
}
}
.skill-title-box {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
overflow: hidden;
.skill-name {
font-size: 28rpx;
font-weight: 600;
color: #333;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-desc {
font-size: 22rpx;
color: #999;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
}
.skill-header-right {
flex-shrink: 0;
.tag {
padding: 4rpx 16rpx;
border-radius: 6rpx;
.tag-text {
font-size: 22rpx;
font-weight: 500;
color: #5147ff;
}
&.tag-bought {
background: rgba(81, 71, 255, 0.1);
}
}
.status-tag {
padding: 4rpx 16rpx;
border-radius: 6rpx;
.status-text {
font-size: 22rpx;
font-weight: 500;
}
&.status-active {
background: rgba(82, 196, 26, 0.1);
.status-text {
color: #52c41a;
}
}
&.status-expired {
background: rgba(255, 77, 79, 0.1);
.status-text {
color: #ff4d4f;
}
}
}
}
}
.skill-body {
display: flex;
flex-direction: column;
gap: 12rpx;
.skill-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
.skill-label {
font-size: 24rpx;
color: #999;
}
.skill-value {
font-size: 24rpx;
color: #333;
font-weight: 500;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,241 @@
import { ref, onMounted } from "vue";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { ACCESS_TOKEN } from "@/constants/home.constants";
import {
apiCreateSubscriptionOrder,
apiCreateCreditOrder,
apiGetOrderCashier,
} from "@/subpackages/servers/subscription";
import { useI18n } from "@/utils/i18n";
import { API_BASE_URL } from "@/constants/config";
import { objectToQueryString } from "@/utils/common";
// 全局单例锁:记录已展示过 Toast 的 订单ID + 支付结果 映射,防止多组件重复提示
const alertedKeys = new Set<string>();
export function useSubscriptionPurchase() {
const { t } = useI18n();
const processingId = ref<number | null>(null);
const loading = ref<boolean>(false);
// #ifdef H5 || WEB
const cleanUrlParams = () => {
// 清空 window.location.search 中的参数
const search = window.location.search;
if (search !== "") {
const params = new URLSearchParams(search);
if (params.has("payResult") || params.has("orderId")) {
params.delete("payResult");
params.delete("orderId");
const searchStr = params.toString();
const newSearch = searchStr ? `?${searchStr}` : "";
const newUrl =
window.location.pathname + newSearch + window.location.hash;
window.history.replaceState(null, "", newUrl);
}
}
// 清空 window.location.hash 中的参数
const hash = window.location.hash;
const qIndex = hash.indexOf("?");
if (qIndex !== -1) {
const pathPart = hash.substring(0, qIndex);
const hashSearch = hash.substring(qIndex);
const params = new URLSearchParams(hashSearch);
if (params.has("payResult") || params.has("orderId")) {
params.delete("payResult");
params.delete("orderId");
const searchStr = params.toString();
const newHash = pathPart + (searchStr ? `?${searchStr}` : "");
const newUrl =
window.location.pathname + window.location.search + newHash;
window.history.replaceState(null, "", newUrl);
}
}
};
const checkPayResult = () => {
const hash = window.location.hash;
const search = window.location.search;
let payResult = "";
let orderId = "";
// 优先从 main search 中解析
if (search !== "") {
const searchParams = new URLSearchParams(search);
payResult = searchParams.get("payResult") || "";
orderId = searchParams.get("orderId") || "";
}
// 若无,从 hash search 中解析
if (payResult === "") {
const qIndex = hash.indexOf("?");
if (qIndex !== -1) {
const hashSearch = hash.substring(qIndex);
const hashParams = new URLSearchParams(hashSearch);
payResult = hashParams.get("payResult") || "";
orderId = hashParams.get("orderId") || "";
}
}
if (payResult !== "") {
const alertKey = `${orderId}_${payResult}`;
if (alertedKeys.has(alertKey)) return;
alertedKeys.add(alertKey);
if (payResult === "success") {
uni.showToast({
title: t("Mobile.MySubscriptions.paySuccess"),
icon: "success",
});
} else if (payResult === "failed") {
uni.showToast({
title: t("Mobile.MySubscriptions.payFailed"),
icon: "none",
});
} else if (payResult === "pending") {
uni.showToast({
title: t("Mobile.MySubscriptions.payPending"),
icon: "none",
});
}
// 清除 URL 参数
cleanUrlParams();
}
};
// #endif
onMounted(() => {
// #ifdef H5 || WEB
checkPayResult();
// #endif
});
const getCashierUrlAndRedirect = async (orderId: number) => {
loading.value = true;
try {
let settlementUrl = "";
// #ifdef H5 || WEB
const token = (uni.getStorageSync(ACCESS_TOKEN) as string) || "";
let settlementOrigin = "";
const hostname = window.location.hostname;
if (hostname === "localhost" || hostname === "127.0.0.1") {
settlementOrigin = "http://localhost:3000";
settlementUrl = `${settlementOrigin}/static/payment-settlement.html?orderId=${orderId}&returnUrl=${encodeURIComponent(window.location.href)}&token=${encodeURIComponent(token)}`;
} else {
settlementOrigin = window.location.origin;
settlementUrl = `${settlementOrigin}/static/payment-settlement.html?orderId=${orderId}&returnUrl=${encodeURIComponent(window.location.href)}`;
}
// #endif
// #ifndef H5 || WEB
const token = (uni.getStorageSync(ACCESS_TOKEN) as string) || "";
const pages = getCurrentPages();
let relativePath = "";
if (pages.length > 0) {
const currentPage = pages[pages.length - 1];
const path = currentPage.route;
const params = objectToQueryString(currentPage.options);
relativePath = params !== "" ? `${path}?${params}` : path;
}
let baseSettlementUrl = API_BASE_URL;
if (process.env.NODE_ENV === "development") {
baseSettlementUrl = "http://localhost:3000";
}
const h5CurrentPageUrl = `${baseSettlementUrl}/m/#/${relativePath}`;
settlementUrl = `${baseSettlementUrl}/static/payment-settlement.html?orderId=${orderId}&returnUrl=${encodeURIComponent(h5CurrentPageUrl)}&token=${encodeURIComponent(token)}&isMp=1`;
// #endif
// 获取收银台地址
const cashierRes = await apiGetOrderCashier(
orderId,
settlementUrl !== "" ? settlementUrl : undefined,
);
if (cashierRes.code === SUCCESS_CODE && cashierRes.data != null) {
const cashierInfo = cashierRes.data!;
// #ifdef H5 || WEB
// H5 环境直接重定向跳转到收银台
window.location.href = cashierInfo.cashierUrl;
// #endif
// #ifndef H5 || WEB
// 非 H5 环境(如 App 或 小程序)则打开内嵌 WebView 进行支付
uni.navigateTo({
url: `/subpackages/pages/webview/webview?url=${encodeURIComponent(cashierInfo.cashierUrl)}`,
});
// #endif
} else {
uni.showToast({
title: t("Mobile.MySubscriptions.orderIdNotFound"),
icon: "none",
});
}
} catch (e) {
uni.showToast({
title: t("Mobile.MySubscriptions.loadFailed"),
icon: "none",
});
} finally {
loading.value = false;
processingId.value = null;
}
};
const handlePaySubscription = async (planId: number) => {
if (processingId.value != null) return;
processingId.value = planId;
try {
const orderRes = await apiCreateSubscriptionOrder(planId);
if (orderRes.code === SUCCESS_CODE && orderRes.data) {
const orderId = orderRes.data.id ?? orderRes.data;
await getCashierUrlAndRedirect(orderId as number);
} else {
uni.showToast({
title: t("Mobile.MySubscriptions.orderIdNotFound"),
icon: "none",
});
processingId.value = null;
}
} catch (e) {
uni.showToast({
title: t("Mobile.MySubscriptions.loadFailed"),
icon: "none",
});
processingId.value = null;
}
};
const handlePayCredits = async (packageId: number) => {
if (processingId.value != null) return;
processingId.value = packageId;
try {
const orderRes = await apiCreateCreditOrder(packageId);
if (orderRes.code === SUCCESS_CODE && orderRes.data) {
const orderId = orderRes.data.id ?? orderRes.data;
await getCashierUrlAndRedirect(orderId as number);
} else {
uni.showToast({
title: t("Mobile.MySubscriptions.orderIdNotFound"),
icon: "none",
});
processingId.value = null;
}
} catch (e) {
uni.showToast({
title: t("Mobile.MySubscriptions.loadFailed"),
icon: "none",
});
processingId.value = null;
}
};
return {
processingId,
loading,
handlePaySubscription,
handlePayCredits,
};
}

View File

@@ -0,0 +1,178 @@
<template>
<view class="subscriptions-container">
<!-- 顶部导航栏 -->
<custom-nav-bar :title="t('Mobile.MySubscriptions.title')">
<template v-slot:left>
<view class="header-icon-box" @tap="jumpNavigateBack">
<text class="iconfont icon-a-Chevronleft"></text>
</view>
</template>
</custom-nav-bar>
<!-- 加载中 -->
<view v-if="pageLoading" class="loading-state">
<text class="loading-text">...</text>
</view>
<!-- 页面内容 -->
<scroll-view v-else scroll-y class="page-content">
<view class="page-scroll-content">
<!-- 积分概览 -->
<CreditsBreakdown
:summary="creditsSummary"
@add-purchase="handleOpenPurchase"
/>
<!-- 订阅套餐 -->
<PlanCards
:plans="plans"
:current-plan-id="currentPlanId"
:current-end-time="currentEndTime"
:current-plan-price="currentPlanPrice"
/>
<!-- 已订阅内容 -->
<SubscribedContent />
</view>
<!-- 积分购买弹窗 -->
<PurchaseModal ref="purchaseModalRef" />
</scroll-view>
</view>
</template>
<script setup lang="uts">
import type {
CreditSummaryInfo,
SystemSubscriptionPlan,
} from "@/subpackages/types/interfaces/subscription";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import {
apiGetCreditSummary,
apiGetMySubscription,
apiListSystemSubscriptionPlans,
} from "@/subpackages/servers/subscription";
import { jumpNavigateBack } from "@/utils/common";
import { setCurrentPageNavigationBarTitle } from "@/utils/system.uts";
import { useI18n } from "@/utils/i18n";
import { TENANT_CONFIG_INFO } from "@/constants/home.constants";
import type { TenantConfigInfo } from "@/types/interfaces/login";
import { apiTenantConfig } from "@/servers/account";
import CreditsBreakdown from "./components/credits-breakdown/credits-breakdown.uvue";
import PlanCards from "./components/plan-cards/plan-cards.uvue";
import SubscribedContent from "./components/subscribed-content/subscribed-content.uvue";
import PurchaseModal from "./components/purchase-modal/purchase-modal.uvue";
const { t } = useI18n();
const pageLoading = ref(true);
const creditsSummary = ref<CreditSummaryInfo | null>(null);
const plans = ref<SystemSubscriptionPlan[]>([]);
const currentPlanId = ref<number | null>(null);
const currentEndTime = ref<string | null>(null);
const currentPlanPrice = ref<number | null>(null);
const purchaseModalRef = ref<any>(null);
const fetchPageData = async () => {
pageLoading.value = true;
try {
const [summaryRes, subRes, plansRes] = await Promise.all([
apiGetCreditSummary(),
apiGetMySubscription({ bizType: "SYSTEM" }),
apiListSystemSubscriptionPlans(),
]);
if (summaryRes.code === SUCCESS_CODE && summaryRes.data) {
creditsSummary.value = summaryRes.data;
}
if (subRes.code === SUCCESS_CODE && subRes.data) {
const current = subRes.data.currentSubscription;
if (current) {
currentPlanId.value = current.planId;
currentEndTime.value = current.endTime;
currentPlanPrice.value = current.plan.price;
}
}
if (plansRes.code === SUCCESS_CODE && plansRes.data) {
plans.value = Array.isArray(plansRes.data) ? plansRes.data : [];
}
} catch (e) {
// ignore
} finally {
pageLoading.value = false;
}
};
const handleOpenPurchase = () => {
purchaseModalRef.value?.open();
};
onLoad(() => {
const checkAndFetchConfig = async () => {
// 1. 优先校验本地缓存
const tenantConfigInfoString = uni.getStorageSync(TENANT_CONFIG_INFO);
if (tenantConfigInfoString) {
try {
const tenantConfig = JSON.parse(
tenantConfigInfoString,
) as TenantConfigInfo;
if (tenantConfig.enableSubscription !== 1) {
uni.showToast({
title:
t("Mobile.MySubscriptions.notEnabled") || "订阅功能暂未开启",
icon: "none",
});
setTimeout(() => {
jumpNavigateBack();
}, 1500);
return;
}
} catch (error) {
console.error("解析租户配置失败:", error);
}
}
// 2. 实时拉取最新配置进行二次校验和本地同步
try {
const res = await apiTenantConfig();
if (res.code === SUCCESS_CODE && res.data != null) {
const latestConfig = res.data!;
uni.setStorageSync(TENANT_CONFIG_INFO, JSON.stringify(latestConfig));
if (latestConfig.enableSubscription !== 1) {
uni.showToast({
title:
t("Mobile.MySubscriptions.notEnabled") || "订阅功能暂未开启",
icon: "none",
});
setTimeout(() => {
jumpNavigateBack();
}, 1500);
return;
}
}
} catch (e) {
console.error("同步最新租户配置失败:", e);
}
// 3. 校验通过,加载页面
setCurrentPageNavigationBarTitle();
fetchPageData();
};
checkAndFetchConfig();
});
onShow(() => {
// 若页面已经初始化完毕(非初次加载),当页面重新显示时自动刷新最新数据
if (!pageLoading.value) {
fetchPageData();
}
});
</script>
<style lang="scss" scoped>
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,42 @@
.subscriptions-container {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f9f9f9;
overflow: hidden;
.header-icon-box {
display: flex;
align-items: center;
justify-content: center;
width: 56rpx;
height: 56rpx;
.iconfont {
font-size: 32rpx;
color: #666;
}
}
.page-content {
flex: 1;
height: 0;
}
.page-scroll-content {
padding: 20rpx 24rpx;
padding-bottom: 40rpx;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 200rpx 0;
.loading-text {
font-size: 28rpx;
color: #999;
}
}
}

View File

@@ -0,0 +1,64 @@
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
export function getPeriodUnit(period: string | null): string {
if (period == null) return "";
const map: Record<string, string> = {
MONTH: t("Mobile.MySubscriptions.perMonth"),
"1": t("Mobile.MySubscriptions.perMonth"),
QUARTER: t("Mobile.MySubscriptions.perQuarter"),
"3": t("Mobile.MySubscriptions.perQuarter"),
YEAR: t("Mobile.MySubscriptions.perYear"),
"12": t("Mobile.MySubscriptions.perYear"),
FOREVER: t("Mobile.MySubscriptions.perForever"),
};
return map[period] || "";
}
export function getFeeLabel(period: string | null): string {
if (period == null) return "";
const map: Record<string, string> = {
MONTH: t("Mobile.MySubscriptions.feeMonth"),
"1": t("Mobile.MySubscriptions.feeMonth"),
QUARTER: t("Mobile.MySubscriptions.feeQuarter"),
"3": t("Mobile.MySubscriptions.feeQuarter"),
YEAR: t("Mobile.MySubscriptions.feeYear"),
"12": t("Mobile.MySubscriptions.feeYear"),
};
return map[period] || "";
}
export function getPayTypeText(period: string | null): string {
if (period == null) return "";
const map: Record<string, string> = {
MONTH: t("Mobile.MySubscriptions.monthlyPayment"),
"1": t("Mobile.MySubscriptions.monthlyPayment"),
QUARTER: t("Mobile.MySubscriptions.quarterlyPayment"),
"3": t("Mobile.MySubscriptions.quarterlyPayment"),
YEAR: t("Mobile.MySubscriptions.yearlyPayment"),
"12": t("Mobile.MySubscriptions.yearlyPayment"),
};
return map[period] || "";
}
export function getStatusText(status: string): string {
const map: Record<string, string> = {
ACTIVE: t("Mobile.MySubscriptions.subscribing"),
EXPIRED: t("Mobile.MySubscriptions.expired"),
CANCELLED: t("Mobile.MySubscriptions.expired"),
};
return map[status] || "";
}
export function isActive(status: string): boolean {
return status === "ACTIVE";
}
export function isForever(period: string | null): boolean {
return period === "FOREVER";
}
export function formatMoney(amount: number): string {
return `¥${amount}`;
}