Pārlūkot izejas kodu

📦 换肤功能接口对接

快乐的梦鱼 2 dienas atpakaļ
vecāks
revīzija
a57c53dfe4

+ 9 - 0
src/api/light/LightVillageApi.ts

@@ -267,6 +267,8 @@ export class VillageListItem extends DataModel<VillageListItem> {
 
   /** 志愿者在村落昵称 */
   villageNickname = '';
+  /** * 当前村社皮肤ID */
+  currentSkinId : number|null = null;
 }
 
 export interface VillageTreeAnimProps {
@@ -447,6 +449,13 @@ export class LightVillageApi extends AppServerRequestModule<DataModel> {
     });
   }
 
+  async updateVillageSkin(id: number, currentSkinId: number) {
+    return await this.post<KeyValue>('/village/village/save', '更新村社皮肤', {
+      id: id,
+      current_skin_id: currentSkinId,
+    });
+  }
+
   async createVillage(data: {
     name: string,
     longitude: string,

+ 191 - 0
src/api/light/SkinApi.ts

@@ -0,0 +1,191 @@
+import { DataModel, transformArrayDataModel, transformDataModel, type KeyValue, type NewDataModel } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from '../RequestModules';
+import { transformSomeToArray } from '../Utils';
+
+export class SkinOrderItem extends DataModel<SkinOrderItem> {
+  constructor() {
+    super(SkinOrderItem, '皮肤订单');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      userId: { clientSide: 'number', serverSide: 'number' },
+      orderType: { clientSide: 'number', serverSide: 'number' },
+      staffLevelId: { clientSide: 'number', serverSide: 'number' },
+      villageId: { clientSide: 'number', serverSide: 'number' },
+      amount: { clientSide: 'number', serverSide: 'number' },
+      days: { clientSide: 'number', serverSide: 'number' },
+      status: { clientSide: 'number', serverSide: 'number' },
+      paytime: { clientSide: 'number', serverSide: 'number' },
+    };
+  }
+
+  id!: number;
+  orderNo = '';
+  userId = null as number | null;
+  /** 订单类型: 6=村社皮肤购买 */
+  orderType = 6;
+  staffLevelId = 0;
+  villageId = null as number | null;
+  amount = 0;
+  days = 0;
+  /** 状态: 0=待支付, 1=已支付 */
+  status = 0;
+  paytime = 0;
+  createtime = '' as string | null;
+  updatetime = '' as string | null;
+  orderTypeText = '';
+  payTypeText = '';
+  statusText = '';
+  paytimeText = '';
+  rewardStatusText = '';
+}
+
+export class SkinOrderConfirm extends DataModel<SkinOrderConfirm> {
+  constructor() {
+    super(SkinOrderConfirm, '皮肤购买下单确认');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      order: {
+        clientSide: 'object',
+        clientSideChildDataModel: SkinOrderItem,
+        serverSide: 'undefined',
+        clientSideRequired: true,
+      },
+      pay: {
+        clientSide: 'object',
+        clientSideChildDataModel: {
+          convertTable: {
+            appId: { clientSide: 'string', clientSideRequired: true },
+            timeStamp: { clientSide: 'string', clientSideRequired: true },
+            nonceStr: { clientSide: 'string', clientSideRequired: true },
+            package: { clientSide: 'string', clientSideRequired: true },
+            signType: { clientSide: 'string', clientSideRequired: true },
+            paySign: { clientSide: 'string', clientSideRequired: true },
+          },
+        },
+        serverSide: 'undefined',
+      },
+    };
+  }
+
+  order!: SkinOrderItem;
+  pay?: {
+    appId: string;
+    timeStamp: string;
+    nonceStr: string;
+    package: string;
+    signType: string;
+    paySign: string;
+  };
+}
+
+export class SkinItem extends DataModel<SkinItem> {
+  constructor() {
+    super(SkinItem, '皮肤套餐');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      price: { clientSide: 'number', serverSide: 'number' },
+      status: { clientSide: 'number', serverSide: 'number' },
+      weigh: { clientSide: 'number', serverSide: 'number' },
+    };
+  }
+
+  id!: number;
+  /** 皮肤名称 */
+  title = '';
+  /** 金额 */
+  price = 0;
+  /** 简介 */
+  desc = '';
+  /** 图片 */
+  image = '';
+  /** 多图 */
+  images = '';
+  /** 皮肤配置 */
+  skinProps = '';
+  /** 状态: 0=禁用, 1=启用 */
+  status = 0;
+  /** 权重 */
+  weigh = 0;
+  /** 是否已购 */
+  isBought = false;
+
+  statusText = '';
+}
+
+type PagedSkinResponse = {
+  total: number;
+  per_page?: number;
+  current_page?: number;
+  last_page?: number;
+  data: KeyValue[];
+};
+
+export class SkinApi extends AppServerRequestModule<DataModel> {
+  constructor() {
+    super();
+  }
+
+  /**
+   * 村社皮肤购买下单
+   * @param skinId 皮肤ID
+   * @param payType 支付方式: 1=线上支付, 2=对公付款, 3=乡源果支付
+   * @param villageId 村社ID(必填)
+   */
+  async placeOrder(skinId: number, payType: 1 | 2 | 3, villageId: number) {
+    const res = await this.post<KeyValue>('/village/growth/placeOrder', '村社皮肤购买下单', {
+      skin_id: skinId,
+      pay_type: payType,
+      village_id: villageId,
+      order_type: 6,
+    });
+    return transformDataModel<SkinOrderConfirm>(SkinOrderConfirm, res.requireData());
+  }
+
+  private parsePagedList<T extends DataModel>(model: NewDataModel, data: PagedSkinResponse, label: string) {
+    return {
+      total: data.total ?? 0,
+      list: transformArrayDataModel<T>(model, transformSomeToArray(data.data), label, true),
+    };
+  }
+
+  /** 皮肤套餐列表 */
+  async getSkinList(options?: {
+    page?: number;
+    pageSize?: number;
+    keywords?: string;
+  }) {
+    const res = await this.post<PagedSkinResponse>('/village/village/skinList', '皮肤套餐列表', {
+      page: options?.page,
+      pageSize: options?.pageSize,
+      keywords: options?.keywords,
+    });
+    return this.parsePagedList<SkinItem>(SkinItem, res.requireData(), '皮肤套餐');
+  }
+
+  /** 已购皮肤列表 */
+  async getPurchasedSkinList(options?: {
+    /** 村落ID(必填) */
+    villageId: number;
+    page?: number;
+    pageSize?: number;
+    keywords?: string;
+  }) {
+    const res = await this.post<PagedSkinResponse>('/village/growth/purchasedSkinList', '已购皮肤列表', {
+      village_id: options?.villageId,
+      page: options?.page,
+      pageSize: options?.pageSize,
+      keywords: options?.keywords,
+    });
+    return this.parsePagedList<SkinItem>(SkinItem, res.requireData(), '已购皮肤');
+  }
+
+  /** 村社皮肤详情 */
+  async getSkinInfo(id: number) {
+    const res = await this.post<KeyValue>('/village/village/skinInfo', '皮肤详情', { id });
+    return transformDataModel<SkinItem>(SkinItem, res.requireData());
+  }
+}
+
+export default new SkinApi();

+ 10 - 0
src/components/dynamic/x/DynamicXNodeChild.vue

@@ -38,9 +38,19 @@
                     <!-- Level 3 -->
                     <DynamicXNodeComponent :node="childNode4">
                       <template #nodeRender="{ node }"><slot name="nodeRender" :node="node" /></template>
+                      <block v-for="(childNode5, index) in childNode4.nodes" :key="childNode5.name || index">
+                        <template #[`renderChild-${index}`]>
+                          <!-- Level 4 -->
+                          <DynamicXNodeComponent :node="childNode5">
+                            <template #nodeRender="{ node }"><slot name="nodeRender" :node="node" /></template>
 
 
 
+                          </DynamicXNodeComponent>
+                        </template>
+                      </block>
+
+
                     </DynamicXNodeComponent>
                   </template>
                 </block>

+ 1 - 5
src/components/feedback/Touchable.vue

@@ -1,10 +1,7 @@
 <template>
   <view
     :id="innerId ?? id"
-    :class="[
-      'nana-flex-layout',
-      innerClass,
-    ]"
+    :class="innerClass"
     :style="finalStyle"
     @mouseenter="handleMouseEnter"
     @mouseleave="handleMouseLeave"
@@ -62,7 +59,6 @@ const finalStyle = computed(() => {
 
 defineOptions({
   options: {
-    styleIsolation: "shared",
     virtualHost: true
   }
 })

+ 68 - 74
src/components/layout/BaseView.ts

@@ -6,107 +6,101 @@ import { ObjectUtils } from "@imengyu/imengyu-utils";
 
 export function useBaseViewStyleBuilder(props: FlexProps) {
   
+  const styleObj : Record<string, any> = {}; 
+
   const themeContext = useTheme();
   const commonStyle = computed(() => {
-    const obj : Record<string, any> = {
-      display: 'flex',
-      flexDirection: props.direction,
-      flexBasis: props.flexBasis,
-      flexGrow: props.flexGrow,
-      flexShrink: props.flexShrink,
-      justifyContent: props.center ? (props.justify || 'center') : props.justify,
-      alignItems: props.center ? (props.align || 'center') : props.align,
-      position: props.position,
-      alignSelf: props.alignSelf,
-      flexWrap: props.wrap ? 'wrap' : 'nowrap',
-      backgroundColor: themeContext.resolveThemeColor(props.backgroundColor),
-      width: themeContext.resolveThemeSize(props.width),
-      height: themeContext.resolveThemeSize(props.height),
-      gap: !Array.isArray(props.gap) ? themeContext.resolveThemeSize(props.gap) : props.gap,
-      rowGap: Array.isArray(props.gap) ? themeContext.resolveSize(props.gap[0]) : undefined,
-      columnGap: Array.isArray(props.gap) ? themeContext.resolveSize(props.gap[1]) : undefined,
-      borderRadius: themeContext.resolveThemeSize(props.radius),
-      overflow: props.overflow,
-      border: props.border && !props.border.includes(' ') ? themeContext.getVar('border.' + props.border, undefined) : props.border,
-      borderColor: themeContext.resolveThemeColor(props.borderColor),
-      borderWidth: themeContext.resolveThemeSize(props.borderWidth),
-      borderStyle: props.borderStyle,
-      boxShadow: props.shadow ? themeContext.getVar('shadow.' + props.shadow, undefined) : undefined,
-      zIndex: props.zIndex,
-      pointerEvents: props.pointerEvents,
-    }
+    styleObj.display = 'flex';
+    styleObj.flexDirection = props.direction;
+    styleObj.flexBasis = props.flexBasis;
+    styleObj.flexGrow = props.flexGrow;
+    styleObj.flexShrink = props.flexShrink;   
+    styleObj.justifyContent = props.center ? (props.justify || 'center') : props.justify;
+    styleObj.alignItems = props.center ? (props.align || 'center') : props.align;
+    styleObj.position = props.position;
+    styleObj.alignSelf = props.alignSelf;
+    styleObj.flexWrap = props.wrap ? 'wrap' : 'nowrap';
+    styleObj.backgroundColor = themeContext.resolveThemeColor(props.backgroundColor);
+    styleObj.width = themeContext.resolveThemeSize(props.width);
+    styleObj.height = themeContext.resolveThemeSize(props.height);
+    styleObj.gap = !Array.isArray(props.gap) ? themeContext.resolveThemeSize(props.gap) : props.gap;
+    styleObj.rowGap = Array.isArray(props.gap) ? themeContext.resolveSize(props.gap[0]) : undefined;
+    styleObj.columnGap = Array.isArray(props.gap) ? themeContext.resolveSize(props.gap[1]) : undefined;
+    styleObj.borderRadius = themeContext.resolveThemeSize(props.radius);
+    styleObj.overflow = props.overflow;
+    styleObj.border = props.border && !props.border.includes(' ') ? themeContext.getVar('border.' + props.border, undefined) : props.border;
+    styleObj.borderColor = themeContext.resolveThemeColor(props.borderColor);
+    styleObj.borderWidth = themeContext.resolveThemeSize(props.borderWidth);
+    styleObj.borderStyle = props.borderStyle;
+    styleObj.boxShadow = props.shadow ? themeContext.getVar('shadow.' + props.shadow, undefined) : undefined;
+    styleObj.zIndex = props.zIndex;
+    styleObj.pointerEvents = props.pointerEvents;
 
     //内边距样式
-    configPadding(obj, themeContext, props.padding);
+    configPadding(styleObj, themeContext, props.padding);
     //外边距样式
-    configMargin(obj, themeContext, props.margin);
+    configMargin(styleObj, themeContext, props.margin);
 
     if (props.innerStyle)
-      ObjectUtils.cloneValuesToObject(props.innerStyle, obj);
+      ObjectUtils.cloneValuesToObject(props.innerStyle, styleObj);
 
-    if (obj.paddingVertical) {
-      if (obj.paddingTop === undefined)
-        obj.paddingTop = obj.paddingVertical;
-      if (obj.paddingBottom === undefined)
-        obj.paddingBottom = obj.paddingVertical;
-      delete obj.paddingVertical;
+    if (styleObj.paddingVertical) {
+      if (styleObj.paddingTop === undefined)
+        styleObj.paddingTop = styleObj.paddingVertical;
+      if (styleObj.paddingBottom === undefined)
+        styleObj.paddingBottom = styleObj.paddingVertical;
+      delete styleObj.paddingVertical;
     }
-    if (obj.paddingHorizontal) {
-      if (obj.paddingLeft === undefined)
-        obj.paddingLeft = obj.paddingHorizontal;
-      if (obj.paddingRight === undefined)
-        obj.paddingRight = obj.paddingHorizontal;
-      delete obj.paddingHorizontal;
+    if (styleObj.paddingHorizontal) {
+      if (styleObj.paddingLeft === undefined)
+        styleObj.paddingLeft = styleObj.paddingHorizontal;
+      if (styleObj.paddingRight === undefined)
+        styleObj.paddingRight = styleObj.paddingHorizontal;
+      delete styleObj.paddingHorizontal;
     }
-    if (obj.marginVertical) {
-      obj.marginTop = obj.marginVertical;
-      obj.marginBottom = obj.marginVertical;
-      delete obj.marginVertical;
+    if (styleObj.marginVertical) {
+      styleObj.marginTop = styleObj.marginVertical;
+      styleObj.marginBottom = styleObj.marginVertical;
+      delete styleObj.marginVertical;
     }
-    if (obj.marginHorizontal) {
-      obj.marginLeft = obj.marginHorizontal;
-      obj.marginRight = obj.marginHorizontal;
-      delete obj.marginHorizontal;
+    if (styleObj.marginHorizontal) {
+      styleObj.marginLeft = styleObj.marginHorizontal;
+      styleObj.marginRight = styleObj.marginHorizontal;
+      delete styleObj.marginHorizontal;
     }
 
     if (props.inset != undefined) {
       if (Array.isArray(props.inset)) {
-        obj.top = themeContext.resolveThemeSize(props.inset[0]);
-        obj.right = themeContext.resolveThemeSize(props.inset[1]);
-        obj.bottom = themeContext.resolveThemeSize(props.inset[2]);
-        obj.left = themeContext.resolveThemeSize(props.inset[3]);
+        styleObj.top = themeContext.resolveThemeSize(props.inset[0]);
+        styleObj.right = themeContext.resolveThemeSize(props.inset[1]);
+        styleObj.bottom = themeContext.resolveThemeSize(props.inset[2]);
+        styleObj.left = themeContext.resolveThemeSize(props.inset[3]);
       } else if (typeof props.inset === 'object') {
-        obj.left = themeContext.resolveThemeSize(props.inset.l);
-        obj.right = themeContext.resolveThemeSize(props.inset.r);
-        obj.top = themeContext.resolveThemeSize(props.inset.t);
-        obj.bottom = themeContext.resolveThemeSize(props.inset.b);
+        styleObj.left = themeContext.resolveThemeSize(props.inset.l);
+        styleObj.right = themeContext.resolveThemeSize(props.inset.r);
+        styleObj.top = themeContext.resolveThemeSize(props.inset.t);
+        styleObj.bottom = themeContext.resolveThemeSize(props.inset.b);
       } else {
         const v = themeContext.resolveThemeSize(props.inset);
-        obj.left = v;
-        obj.right = v;
-        obj.top = v;
-        obj.bottom = v;
+        styleObj.left = v;
+        styleObj.right = v;
+        styleObj.top = v;
+        styleObj.bottom = v;
       }
     }
 
     //绝对距样式
     if (typeof props.left !== 'undefined')
-      obj.left = themeContext.resolveThemeSize(props.left);
+      styleObj.left = themeContext.resolveThemeSize(props.left);
     if (typeof props.right !== 'undefined')
-      obj.right = themeContext.resolveThemeSize(props.right);
+      styleObj.right = themeContext.resolveThemeSize(props.right);
     if (typeof props.top !== 'undefined')
-      obj.top = themeContext.resolveThemeSize(props.top);
+      styleObj.top = themeContext.resolveThemeSize(props.top);
     if (typeof props.bottom !== 'undefined')
-      obj.bottom = themeContext.resolveThemeSize(props.bottom);
+      styleObj.bottom = themeContext.resolveThemeSize(props.bottom);
     if (typeof props.flex !== 'undefined')
-      obj.flex = props.flex;
-
-    for (const key in obj) {
-      if (obj[key] === undefined)
-        delete obj[key];
-    }
-
-    return obj
+      styleObj.flex = props.flex;
+    return styleObj
   });
 
   return {

+ 2 - 21
src/components/layout/FlexView.vue

@@ -1,13 +1,7 @@
 <template>
   <view
     :id="innerId ?? id"
-    :class="[
-      'nana-flex-layout', { 
-        'nana-flex-row': direction === 'row', 
-        'nana-flex-column': direction === 'column' 
-      },
-      innerClass,
-    ]"
+    :class="innerClass"
     :style="finalStyle"
   >
     <slot />
@@ -172,7 +166,6 @@ const finalStyle = computed(() => {
 
 defineOptions({
   options: {
-    styleIsolation: "shared",
     virtualHost: true
   }
 });
@@ -191,16 +184,4 @@ defineExpose({
     });
   },
 })
-</script>
-
-<style>
-.nana-flex-layout {
-  display: flex;
-}
-.nana-flex-row {
-  flex-direction: row;
-}
-.nana-flex-column {
-  flex-direction: column;
-}
-</style>
+</script>

+ 1 - 1
src/components/loader/SimplePageContentLoader.vue

@@ -18,7 +18,7 @@
     </Empty>
   </view> 
   <view
-    v-if="showEmpty || loader?.status.value == 'nomore'"
+    v-if="showEmpty || loader?.status.value == 'nomore' || loader?.status.value == 'empty'"
     class="loader-view"
   >
     <Empty

+ 14 - 0
src/components/utils/PageAction.ts

@@ -83,6 +83,19 @@ function callPrevOnPageBack(name: string, data: Record<string, unknown>, index:
   if (prevPage && typeof prevPage.$vm.onPageBack === 'function')
     prevPage.$vm.onPageBack(name, data);
 }
+
+/**
+ * 页面数据传递: 调用顶层页面的 onPageBack 方法
+ * @param name 方法名
+ * @param data 要传递的数据
+ */
+function callTopOnPageBack(name: string, data: Record<string, unknown>, index: number = 0) {
+  var pages = getCurrentPages(); // 获取页面栈
+  var prevPage = pages[0] as { $vm: Record<string, unknown> }; // 上一个页面
+
+  if (prevPage && typeof prevPage.$vm.onPageBack === 'function')
+    prevPage.$vm.onPageBack(name, data);
+}
 /**
  * 页面跳转: 调用上一个页面的 onPageBack 方法并返回至上一个页面
  * @param name 方法名
@@ -122,6 +135,7 @@ export {
   backAndCallOnPageBack, 
   navTo,
   callPrevOnPageBack,
+  callTopOnPageBack,
   isTopLevelPage,
 }
 

+ 7 - 0
src/pages.json

@@ -357,6 +357,13 @@
       }
     },
     {
+      "path": "pages/home/village/introd/skin/buy-skin",
+      "style": {
+        "navigationBarTitleText": "购买皮肤",
+        "navigationStyle": "custom"
+      }
+    },
+    {
       "path": "pages/home/village/introd/config/config-tags",
       "style": {
         "navigationBarTitleText": "管理贴图话题列表",

+ 16 - 4
src/pages/home/village/introd/card.vue

@@ -471,6 +471,7 @@ import type { DynamicXPage } from '@/components/dynamic/DynamicX';
 import DynamicXNodeChild from '@/components/dynamic/x/DynamicXNodeChild.vue';
 import SimplePageContentLoader from '@/components/loader/SimplePageContentLoader.vue';
 import HomeLargeTitle from '@/common/components/parts/HomeLargeTitle.vue';
+import SkinApi from '@/api/light/SkinApi';
 
 const emit = defineEmits<{
   (e: 'goTree'): void;
@@ -628,7 +629,9 @@ function handleGoGallery() {
     maxCount: villageStore.currentVillage?.imageLimit || 3,
   });
 }
-function handleGoSkin() {
+async function handleGoSkin() {
+  if (!await checkIsStaff())
+    return;
   navTo('/pages/home/village/introd/config', { 
     villageId: villageStore.currentVillage?.id ?? 0 ,
   });
@@ -718,17 +721,23 @@ const pageScriptGlobal = reactive({
 const topBanner = inject('topBanner') as Ref<string>;
 
 const pageThemeLoader = useSimpleDataLoader(async () => {
+  assertNotNull(villageStore.currentVillage)
   //TODO: 加载村社主题
-  const page = DefaultCard as DynamicXPage;
+  const theme = (villageStore.currentVillage.currentSkinId === 1 || !villageStore.currentVillage.currentSkinId) ?
+    DefaultCard :
+    JSON.parse((await SkinApi.getSkinInfo(villageStore.currentVillage.currentSkinId)).skinProps);
+  const page = theme as DynamicXPage;
   topBanner.value = page.props?.topBanner || 'https://xy.wenlvti.net/app_static/images/home/BannerHome.png';
   return page;
-});
+}, false);
 const tagsLoader = useSimpleDataLoader(async () => {
   //TODO: 加载村社话题
   return defaultTags;
-});
+}, false);
 
 watch(() => villageStore.currentVillage, async () => {
+  await pageThemeLoader.load();
+  await tagsLoader.load();
   await waitTimeOut(100);
   await villageInfoLoader.reload();
   await waitTimeOut(100);
@@ -749,6 +758,9 @@ defineExpose({
       upgradeRef.value?.show();
     } else if (name === 'backShowVillageJoin') {
       handleGoJoin();
+    } else if (name === 'backRefreshSkin') {
+      pageThemeLoader.load();
+      tagsLoader.load();  
     } 
   },
 });

+ 14 - 19
src/pages/home/village/introd/config.vue

@@ -52,40 +52,35 @@ import Icon from '@/components/basic/Icon.vue';
 import Image from '@/components/basic/Image.vue';
 import Text from '@/components/basic/Text.vue';
 import { useSimpleDataLoader } from '@/components/composeabe/loader/SimpleDataLoader';
+import { useLoadQuerys } from '@/components/composeabe/LoadQuerys';
+import { navTo } from '@/components/utils/PageAction';
 import Touchable from '@/components/feedback/Touchable.vue';
 import FlexCol from '@/components/layout/FlexCol.vue';
 import FlexRow from '@/components/layout/FlexRow.vue';
 import SimplePageContentLoader from '@/components/loader/SimplePageContentLoader.vue';
-import { navTo } from '@/components/utils/PageAction';
+import SkinApi from '@/api/light/SkinApi';
+import { toast } from '@/components/utils/DialogAction';
+
+const { querys } = useLoadQuerys({
+  villageId: 0,
+});
 
 const recommendLoader = useSimpleDataLoader(async () => {
-  return [
-    {
-      id: 1,
-      title: '默认皮肤',
-      desc: '默认的村社名片',
-      image: 'https://xy.wenlvti.net/app_static/images/village/skin/DefaultA.jpg',
-    },
-    {
-      id: 2,
-      title: '古风主题',
-      desc: '古风凸显村社的特色',
-      image: 'https://xy.wenlvti.net/app_static/images/village/skin/1/A.jpg',
-    },
-  ];
+  return (await SkinApi.getSkinList({ page: 1, pageSize: 2 })).list
 });
 
 const handleGoRecommendDetails = (item: { id: number }) => {
-  navTo('/pages/home/village/introd/skin/details', { id: item.id })
+  navTo('/pages/home/village/introd/skin/details', { id: item.id, villageId: querys.value.villageId })
 }
 const handleGoRecommendMore = () => {
-  navTo('/pages/home/village/introd/skin/skin');
+  navTo('/pages/home/village/introd/skin/skin', { villageId: querys.value.villageId });
 }
 const handleGoConfigMain = () => {
-  navTo('/pages/home/village/introd/config/config-main');
+  toast('即将上线,敬请期待');
+  //navTo('/pages/home/village/introd/config/config-main', { villageId: querys.value.villageId });
 }
 const handleGoConfigTags = () => {
-  navTo('/pages/home/village/introd/config/config-tags');
+  navTo('/pages/home/village/introd/config/config-tags', { villageId: querys.value.villageId });
 }
 
 </script>

+ 11 - 14
src/pages/home/village/introd/skin/buied.vue

@@ -4,7 +4,7 @@
       <SimplePageContentLoader :loader="skinLoader">
         <FlexRow gap="gap.lg" align="stretch" justify="space-between" wrap>
           <IndexRoundSimpleItem
-            v-for="item in skinLoader.content.value"
+            v-for="item in skinLoader.list.value"
             :key="item.id"
             :item="item"
             :imageHeight="400"
@@ -17,29 +17,26 @@
 </template>
 
 <script setup lang="ts">
-import { useSimpleDataLoader } from '@/components/composeabe/loader/SimpleDataLoader';
+import { useSimplePageListLoader } from '@/components/composeabe/loader/SimplePageListLoader';
+import { useLoadQuerys } from '@/components/composeabe/LoadQuerys';
 import { navTo } from '@/components/utils/PageAction';
 import CommonTopBanner from '@/common/components/CommonTopBanner.vue';
 import IndexRoundSimpleItem from '@/common/components/parts/IndexRoundSimpleItem.vue';
 import FlexCol from '@/components/layout/FlexCol.vue';
 import FlexRow from '@/components/layout/FlexRow.vue';
 import SimplePageContentLoader from '@/components/loader/SimplePageContentLoader.vue';
+import SkinApi from '@/api/light/SkinApi';
 
-const skinLoader = useSimpleDataLoader(async () => {
-  //TODO: 缺少接口
-  return [
-    {
-      id: 1,
-      title: '默认皮肤',
-      desc: '默认的村社名片',
-      image: 'https://xy.wenlvti.net/app_static/images/village/skin/DefaultA.jpg',
-      price: 0,
-    },
-  ];
+const { querys } = useLoadQuerys({
+  villageId: 0,
+});
+
+const skinLoader = useSimplePageListLoader(20, async (page, pageSize) => {
+  return await SkinApi.getPurchasedSkinList({ villageId: querys.value.villageId, page, pageSize });
 });
 
 const handleGoSkinDetails = (item: { id: number }) => {
-  navTo('/pages/home/village/introd/skin/details', { id: item.id })
+  navTo('/pages/home/village/introd/skin/details', { id: item.id, villageId: querys.value.villageId })
 }
 
 </script>

+ 106 - 0
src/pages/home/village/introd/skin/buy-skin.vue

@@ -0,0 +1,106 @@
+
+
+<template>
+  <CommonTopBanner 
+    title="购买皮肤"
+    showNav
+  >
+    <FlexCol gap="gap.lg" padding="padding.md">
+      
+      <FlexCol padding="padding.md" center>
+        <Text 
+          text="请选择您要付款方式" 
+          fontConfig="contentText" :fontSize="30" textAlign="center" 
+        />
+      </FlexCol>
+
+      <BuyFruitInfo :price="querys?.price || 0" @pay="handleDirectPay(3)">
+        <template #prepend>
+          <BoxMid direction="row" justify="space-between" align="center" gap="gap.md">
+            <FlexCol width="74%">
+              <Text text="在线支付" fontConfig="lightImportantTitle" :fontSize="42" />
+              <Text text="若乡源果不足,还支持微信线支付方式,立即生效" fontConfig="lightGoldTitle" :fontSize="30" />
+            </FlexCol>
+            <FrameButton primary text="选择" @click="handleDirectPay(1)" />
+          </BoxMid>
+        </template>
+      </BuyFruitInfo>
+    </FlexCol>
+  </CommonTopBanner>
+</template>
+
+<script setup lang="ts">
+import { useLoadQuerys } from '@/components/composeabe/LoadQuerys';
+import { useRequireLogin } from '@/common/composeabe/RequireLogin';
+import { backAndCallOnPageBack } from '@/components/utils/PageAction';
+import { showError } from '@/common/composeabe/ErrorDisplay';
+import BoxMid from '@/common/components/box/BoxMid.vue';
+import CommonTopBanner from '@/common/components/CommonTopBanner.vue';
+import FrameButton from '@/common/components/FrameButton.vue';
+import Text from '@/components/basic/Text.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import BuyFruitInfo from '../../upgrade/components/BuyFruitInfo.vue';
+import SkinApi from '@/api/light/SkinApi';
+
+const { requireLoginAsync } = useRequireLogin();
+
+function handlePaySuccess() {
+  setTimeout(() => {
+    backAndCallOnPageBack('paySuccessAndRefresh', {});
+  }, 100);
+}
+
+const { querys } = useLoadQuerys({
+  villageId: 0,
+  skinId: 0,
+  price: 0,
+}, () => {
+
+});
+
+async function handleDirectPay(payType: 1 | 3) {
+  if (!requireLoginAsync('登录后为村社升级,做出你的皮肤购买哦'))
+    return;
+  try {
+    uni.showLoading({ title: '创建订单中...' });
+    const { order: orderInfo, pay: payInfo } = await SkinApi.placeOrder(
+      querys.value.skinId,
+      payType,
+      querys.value.villageId,
+    );
+    if (payType === 1) {
+      if (!payInfo) 
+        throw new Error('支付信息不存在');
+      uni.requestPayment({
+        provider: 'wxpay',
+        appId: payInfo.appId,
+        timeStamp: payInfo.timeStamp,
+        nonceStr: payInfo.nonceStr,
+        package: payInfo.package,
+        signType: payInfo.signType,
+        paySign: payInfo.paySign,
+        success: () => {
+          handlePaySuccess();
+        },
+        fail: (err) => {
+          showError(`支付失败: ${err.errMsg}`);
+        },
+      });
+    } else if (payType === 3) { 
+      handlePaySuccess();
+    }
+  } catch (e) {
+    showError(e);
+  } finally {
+    uni.hideLoading();
+  }
+}
+
+defineExpose({
+  onPageBack(name: string, data: any) {
+    if (name === 'handlePaySuccess') {
+      handlePaySuccess();
+    }
+  },
+});
+</script>

+ 65 - 20
src/pages/home/village/introd/skin/details.vue

@@ -23,7 +23,7 @@
           <FlexRow justify="space-between" align="center">
             <Text fontConfig="contentSpeicalText" fontFamily="SongtiSCBlack" :lines="1" :wrap="false">{{ skin.title }}</Text>
             <FlexCol backgroundColor="text.gold" radius="10" padding="10">
-              <Text v-if="skin.price > 0" fontSize="fontSize.sm" fontWeight="bold" color="white" :text="`${skin.price}乡源果`" />
+              <Text v-if="skin.price > 0" fontSize="fontSize.sm" fontWeight="bold" color="white" :text="`${moneyToFruit(skin.price)}乡源果`" />
               <Text v-else fontSize="fontSize.sm" color="white" text="免费" />
             </FlexCol>
           </FlexRow>
@@ -43,6 +43,12 @@
 <script setup lang="ts">
 import { computed } from 'vue';
 import { useSimpleDataLoader } from '@/components/composeabe/loader/SimpleDataLoader';
+import { useFruitConversion } from '@/pages/home/composeabe/FruitConverit';
+import { useLoadQuerys } from '@/components/composeabe/LoadQuerys';
+import { toast, alert } from '@/components/dialog/CommonRoot';
+import { navTo, callTopOnPageBack } from '@/components/utils/PageAction';
+import { assertNotNull } from '@imengyu/imengyu-utils';
+import { showError } from '@/common/composeabe/ErrorDisplay';
 import CommonTopBanner from '@/common/components/CommonTopBanner.vue';
 import FlexCol from '@/components/layout/FlexCol.vue';
 import SimplePageContentLoader from '@/components/loader/SimplePageContentLoader.vue';
@@ -50,31 +56,70 @@ import FlexRow from '@/components/layout/FlexRow.vue';
 import Image from '@/components/basic/Image.vue';
 import Text from '@/components/basic/Text.vue';
 import PrimaryButton from '@/common/components/PrimaryButton.vue';
+import SkinApi from '@/api/light/SkinApi';
+import LightVillageApi from '@/api/light/LightVillageApi';
 
 const IMAGE_WIDTH = 330;
 
+const { querys } = useLoadQuerys({
+  villageId: 0,
+  id: 0
+}, () => skinLoader.load());
+
+const { moneyToFruit } = useFruitConversion();
+
 const skinLoader = useSimpleDataLoader(async () => {
-  //TODO: 缺少接口
-  return {
-    id: 1,
-    title: '默认皮肤',
-    desc: '默认的村社名片',
-    image: 'https://xy.wenlvti.net/app_static/images/village/skin/DefaultA.jpg',
-    images: [
-      'https://xy.wenlvti.net/app_static/images/village/skin/DefaultA.jpg',
-      'https://xy.wenlvti.net/app_static/images/village/skin/DefaultB.jpg',
-    ],
-    price: 0,
-    isBought: false,
-  };
-})
+  const list = await SkinApi.getPurchasedSkinList({ villageId: querys.value.villageId, page: 1, pageSize: 50 });
+  const details = await SkinApi.getSkinInfo(querys.value.id);
+  details.isBought = Boolean(list.list.find(p => p.id === details.id));
+  return details;
+}, false)
 const skin = computed(() => skinLoader.content.value);
 
-function handleBuy() {
-  //TODO
-}
+async function handleBuy() {
+  try {
+    assertNotNull(skin.value);
+    if (skin.value.price <= 0) {
+      await SkinApi.placeOrder(
+        querys.value.id,
+        3,
+        querys.value.villageId,
+      );
+      toast('购买成功');
+      skinLoader.load();
+      return;
+    }
+  } catch (e) {
+    showError(e);
+    return;
+  }
 
-function handleUse() {
-  //TODO
+  navTo('/pages/home/village/introd/skin/buy-skin', { skinId: querys.value.id, villageId: querys.value.villageId, price: skin.value.price, })
 }
+async function handleUse() {
+  try {
+    await LightVillageApi.updateVillageSkin(
+      querys.value.villageId, 
+      querys.value.id
+    );
+    alert({
+      title: '使用成功',
+      content: '您的村社皮肤已更新,请返回村社首页查看效果!',
+    });
+    callTopOnPageBack('backRefreshSkin', {});
+  } catch (e) {
+    showError(e);
+    return;
+  }
+}
+
+defineExpose({
+  onPageBack(name: string, data: any) {
+    if (name === 'paySuccessAndRefresh') {
+      skinLoader.load();
+      toast('购买成功');
+    }
+  },
+});
+
 </script>

+ 14 - 80
src/pages/home/village/introd/skin/skin.vue

@@ -2,13 +2,13 @@
   <CommonTopBanner title="皮肤中心">
     <FlexCol padding="padding.md" gap="gap.lg">
       <FlexRow justify="flex-end" gap="gap.md">
-        <Button @click="handleGoSkinOrders()">查看订单</Button>
+        <!-- <Button @click="handleGoSkinOrders()">查看订单</Button> -->
         <Button @click="handleGoSkinBuied()">我的皮肤</Button>
       </FlexRow>
       <SimplePageContentLoader :loader="skinLoader">
         <FlexRow gap="gap.lg" align="stretch" justify="space-between" wrap>
           <IndexRoundSimpleItem
-            v-for="item in skinLoader.content.value"
+            v-for="item in skinLoader.list.value"
             :key="item.id"
             :item="item"
             :imageHeight="400"
@@ -28,8 +28,9 @@
 </template>
 
 <script setup lang="ts">
-import { useSimpleDataLoader } from '@/components/composeabe/loader/SimpleDataLoader';
+import { useSimplePageListLoader } from '@/components/composeabe/loader/SimplePageListLoader';
 import { navTo } from '@/components/utils/PageAction';
+import { useLoadQuerys } from '@/components/composeabe/LoadQuerys';
 import CommonTopBanner from '@/common/components/CommonTopBanner.vue';
 import IndexRoundSimpleItem from '@/common/components/parts/IndexRoundSimpleItem.vue';
 import FlexCol from '@/components/layout/FlexCol.vue';
@@ -37,91 +38,24 @@ import FlexRow from '@/components/layout/FlexRow.vue';
 import SimplePageContentLoader from '@/components/loader/SimplePageContentLoader.vue';
 import Text from '@/components/basic/Text.vue';
 import Button from '@/components/basic/Button.vue';
+import SkinApi from '@/api/light/SkinApi';
 
-const skinLoader = useSimpleDataLoader(async () => {
-  //TODO: 缺少接口
-  return [
-    {
-      id: 1,
-      title: '默认皮肤',
-      desc: '默认的村社名片',
-      image: 'https://xy.wenlvti.net/app_static/images/village/skin/DefaultA.jpg',
-      price: 0,
-    },
-    {
-      id: 2,
-      title: '古风主题',
-      desc: '古风凸显村社的特色',
-      image: 'https://xy.wenlvti.net/app_static/images/village/skin/1/A.jpg',
-      price: 99,
-    },
-    {
-      id: 3,
-      title: '默认皮肤',
-      desc: '默认的村社名片',
-      image: 'https://xy.wenlvti.net/app_static/images/village/skin/DefaultA.jpg',
-      price: 199,
-    },
-    {
-      id: 4,
-      title: '古风主题',
-      desc: '古风凸显村社的特色',
-      image: 'https://xy.wenlvti.net/app_static/images/village/skin/1/A.jpg',
-      price: 189,
-    },
-    {
-      id: 5,
-      title: '默认皮肤',
-      desc: '默认的村社名片',
-      image: 'https://xy.wenlvti.net/app_static/images/village/skin/DefaultA.jpg',
-      price: 199,
-    },
-    {
-      id: 6,
-      title: '古风主题',
-      desc: '古风凸显村社的特色',
-      image: 'https://xy.wenlvti.net/app_static/images/village/skin/1/A.jpg',
-      price: 189,
-    },
-    {
-      id: 7,
-      title: '默认皮肤',
-      desc: '默认的村社名片',
-      image: 'https://xy.wenlvti.net/app_static/images/village/skin/DefaultA.jpg',
-      price: 199,
-    },
-    {
-      id: 8,
-      title: '古风主题',
-      desc: '古风凸显村社的特色',
-      image: 'https://xy.wenlvti.net/app_static/images/village/skin/1/A.jpg',
-      price: 189,
-    },
-    {
-      id: 9,
-      title: '默认皮肤',
-      desc: '默认的村社名片',
-      image: 'https://xy.wenlvti.net/app_static/images/village/skin/DefaultA.jpg',
-      price: 199,
-    },
-    {
-      id: 10,
-      title: '古风主题',
-      desc: '古风凸显村社的特色',
-      image: 'https://xy.wenlvti.net/app_static/images/village/skin/1/A.jpg',
-      price: 189,
-    },
-  ];
+const { querys } = useLoadQuerys({
+  villageId: 0,
+});
+
+const skinLoader = useSimplePageListLoader(20, async (page, pageSize) => {
+  return (await (SkinApi.getSkinList({ page, pageSize })))
 });
 
 const handleGoSkinDetails = (item: { id: number }) => {
-  navTo('/pages/home/village/introd/skin/details', { id: item.id })
+  navTo('/pages/home/village/introd/skin/details', { id: item.id, villageId: querys.value.villageId })
 }
 const handleGoSkinOrders = () => {
-  navTo('/pages/home/village/introd/skin/orders')
+  navTo('/pages/home/village/introd/skin/orders', { villageId: querys.value.villageId })
 }
 const handleGoSkinBuied = () => {
-  navTo('/pages/home/village/introd/skin/buied')
+  navTo('/pages/home/village/introd/skin/buied', { villageId: querys.value.villageId })
 }
 
 </script>