소스 검색

📦 文章反馈功能

快乐的梦鱼 1 개월 전
부모
커밋
fd3229baa7

+ 35 - 0
src/api/CommonContent.ts

@@ -272,6 +272,9 @@ export class GetContentDetailItem extends DataModel<GetContentDetailItem> {
   id = 0;
   from = '';
   modelId = 0;
+  modelName = '';
+  mainBodyColumnId = 0;
+  mainBodyColumnName = '';
   type = 0;
   title = '';
   region = 0;
@@ -324,6 +327,29 @@ export class CategoryListItem extends DataModel<CategoryListItem> {
   haschild = false;
   children?: CategoryListItem[];
 }
+export class FeedBackItem extends DataModel<FeedBackItem> {
+  constructor() {
+    super(FeedBackItem, "内容反馈");
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {};
+    this._afterSolveClient = (data) => {
+      data.page_url = `${this.modelName}/${data.mainBodyColumnName}/${data.title}\n` + 
+        `URL: { modelId: ${data.modelId}, mainBodyColumnId: ${data.mainBodyColumnId}, contentId: ${data.contentId} }`;
+    }
+  }
+
+  type = null as number|null;
+  content = '';
+  images = [] as string[];
+  contact = '';
+  
+  contentId = 0;
+  title = '';
+  modelId = 0;
+  modelName = '';
+  mainBodyColumnId = 0;
+  mainBodyColumnName = '';
+}
 
 export class CommonContentApi extends AppServerRequestModule<DataModel> {
 
@@ -518,6 +544,15 @@ export class CommonContentApi extends AppServerRequestModule<DataModel> {
       })
     })
   }
+
+  /**
+   * 内容反馈
+   * @param data 
+   * @returns 
+   */
+  async feedBack(data: FeedBackItem) {
+    return (this.post('/user/feedback', data, '内容反馈'));
+  }
 }
 
 export default new CommonContentApi(undefined, 0, '默认通用内容');

+ 2 - 1
src/common/components/tabs/tabbar.vue

@@ -7,7 +7,8 @@
     :innerStyle="{ 
       zIndex: 999 ,
       boxShadow: '0 -2rpx 4rpx rgba(0, 0, 0, 0.1)',
-      backgroundColor: '#f7f3e8',
+      backdropFilter: 'blur(10px)',
+      backgroundColor: 'rgba(246, 242, 231, 0.7)',
     }"
   >
     <TabBarItem icon="https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_home_off.png" activeIcon="https://mncdn.wenlvti.net/app_static/minnan/images/tabs/icon_home_on.png" text="首页" />

+ 7 - 1
src/components/basic/CellGroup.vue

@@ -1,5 +1,5 @@
 <template>
-  <FlexCol :style="{ width: '100%' }">
+  <FlexCol :flex="1">
     <text v-if="title" :style="(titleSpeicalStyle as any)">
       {{ title }}
     </text>
@@ -87,4 +87,10 @@ const insetViewStyle = computed(() => ({
   flexDirection: 'column',
 }));
 
+
+defineOptions({
+  options: {
+    virtualHost: true,
+  }
+})
 </script>

+ 1 - 0
src/components/dynamic/group/FormArrayGroup.vue

@@ -1,4 +1,5 @@
 <template>
+  <!--TODO: 请修改为统一样式 -->
   <div class="dynamic-form-array-group">
     <!--列表-->
     <div :class="['list', direction ]">

+ 50 - 34
src/components/dynamic/group/FormGroup.vue

@@ -1,29 +1,48 @@
 <template>
-  <div :class="[
-    'dynamic-form-group', 
-    {
-      'collapsed': collapsed,
-      'collapsible': collapsible,
-      'plain': plain,
-    }
-  ]">
-    <h5 v-if="title" class="title" @click="collapsible ? collapsed = !collapsed : null">
-      <span class="title-text">{{ title }}</span>
-      <view class="title-right">
-        <span class="title-right-text" v-show="collapsed">点击展开更多</span>
-        <Icon :size="22" icon="arrow-down" v-if="collapsible" innerClass="collapsible-icon" />
-      </view>
-    </h5>
+  <FlexCol 
+    :innerClass="[
+      'dynamic-form-group', 
+      {
+        'collapsed': collapsed,
+        'collapsible': collapsible,
+        'plain': plain,
+      }
+    ]"
+    :innerStyle="dynamicFormGroupStyle"
+  >
+    <Touchable
+      v-if="title" 
+      innerClass="title" 
+      direction="row"
+      :innerStyle="dynamicFormGroupTitleWraperStyle"
+      @click="collapsible ? collapsed = !collapsed : null"
+    >
+      <span :style="dynamicFormGroupTitleStyle">{{ title }}</span>
+      <FlexRow align="center">
+        <text :style="dynamicFormGroupTitleStyle" v-show="collapsed">点击展开更多</text>
+        <Icon 
+          v-if="collapsible"
+          :size="theme.resolveThemeSize('DynamicFormGroupIconSize', 22)" 
+          icon="arrow-down"
+          innerClass="collapsible-icon"
+        />
+      </FlexRow>
+    </Touchable>
     <Row v-if="!collapsed" :justify="(justify as any)" :gutter="gutter">
       <slot />
     </Row>
-  </div>
+  </FlexCol>
 </template>
 
 <script lang="ts" setup>
-import { ref } from "vue";
+import { computed, ref } from "vue";
 import Row from "@/components/layout/grid/Row.vue";
 import Icon from "@/components/basic/Icon.vue";
+import { useTheme } from "@/components/theme/ThemeDefine";
+import FlexRow from "@/components/layout/FlexRow.vue";
+import Touchable from "@/components/feedback/Touchable.vue";
+import FlexCol from "@/components/layout/FlexCol.vue";
+
 const props = defineProps({
   /**
    * 标题
@@ -68,8 +87,22 @@ const props = defineProps({
     default: false,
   },
 });
+const theme = useTheme();
 
 const collapsed = ref(props.collapsed);
+const dynamicFormGroupStyle = computed(() => ({
+  backgroundColor: theme.resolveThemeColor('DynamicFormGroupBackgroundColor', 'white'),
+  borderRadius: theme.resolveThemeSize('DynamicFormGroupBorderRadius', 10),
+  padding: `${theme.resolveThemeSize('DynamicFormGroupPaddingVertical', 10)} ${theme.resolveThemeSize('DynamicFormGroupPaddingHorizontal', 0)}`,
+  marginBottom: theme.resolveThemeSize('DynamicFormGroupMarginBottom', 12),
+})); 
+const dynamicFormGroupTitleWraperStyle = computed(() => ({
+  marginBottom: theme.resolveThemeSize('DynamicFormGroupTitleMarginBottom', 12),
+})); 
+const dynamicFormGroupTitleStyle = computed(() => ({
+  fontSize: theme.resolveThemeSize('DynamicFormGroupTitleFontSize', 25),
+  color: theme.resolveThemeColor('DynamicFormGroupTitleColor', 'text.second')
+})); 
 
 defineOptions({
   options: {
@@ -82,10 +115,6 @@ defineOptions({
 
 <style lang="scss">
 .dynamic-form-group {
-  padding: 10px 0;
-  background-color: var(--dynamic-form-background-color);
-  border-radius: var(--dynamic-form-border-radius);
-
   &.collapsed {
     .collapsible-icon {
       transform: rotate(0deg);
@@ -96,32 +125,19 @@ defineOptions({
       cursor: pointer;
     }
   }
-
   .collapsible-icon {
     transform: rotate(180deg);
     transition: transform 0.3s ease-in-out;
-    width: 16px;
-    height: 16px;
   }
-
   .title {
     display: flex;
     align-items: center;
     justify-content: space-between;
-    color: var(--dynamic-form-text-color);
-    margin: 0;
-    margin-bottom: 12px;
 
     .title-right {
       display: flex;
       flex-direction: row;
       align-items: center;
-
-      .title-right-text {
-        font-size: 11px;
-        margin-right: 10rpx;
-        color: var(--dynamic-form-secondary-color);
-      }
     }
   }
 

+ 68 - 33
src/components/dynamic/wrappers/CheckBoxList.vue

@@ -1,5 +1,14 @@
 <template>
-  <FlexView :direction="vertical ? 'column' : 'row'" align="center" :gap="10" wrap>
+  <FlexView 
+    position="relative"
+    :direction="vertical ? 'column' : 'row'" 
+    :align="vertical ? undefined : 'center'" 
+    :justify="vertical ? 'center' : undefined"
+    :gap="10"
+    :flexGrow="1"
+    :wrap="!vertical"
+    :innerStyle="innerStyle"
+  >
     <ActivityIndicator v-if="loadStatus === 'loading'" />
     <Alert
       v-else-if="loadStatus === 'error'" 
@@ -15,19 +24,55 @@
       :multiple="multiple"
       @update:modelValue="handleChange" 
     >
-      <CheckBox
-        v-for="value in data2"
-        :key="value.value"
-        :name="value.value"
-        :text="value.text" 
-        :disabled="value.disable"
-      />
+      <template v-if="useCell">
+        <Cell
+          v-for="value in data2"
+          :key="value.value"
+          :name="value.value"
+          :disabled="value.disable"
+        >
+          <CheckBox
+            checkPosition="right" 
+            block 
+            :name="value.value"
+            :text="value.text"
+            :disabled="value.disable"
+          >
+            <template #extraText>
+              <Text
+                fontConfig="subSecondText"
+                :text="value.desc" 
+                :innerStyle="{ maxWidth: '100%' }"
+              />
+            </template>
+          </CheckBox>
+        </Cell>
+      </template>
+      <template v-else>
+        <CheckBox
+          v-for="value in data2"
+          :key="value.value"
+          :name="value.value"
+          :text="value.text" 
+          :disabled="value.disable"
+        >
+          <template #extraText>
+            <Text
+              fontConfig="subSecondText"
+              :text="value.desc" 
+              :innerStyle="{ maxWidth: '100%' }"
+            />
+          </template>
+        </CheckBox>
+      </template>
     </CheckBoxGroup>
   </FlexView>
 </template>
 
 <script setup lang="ts">
 import ActivityIndicator from '@/components/basic/ActivityIndicator.vue';
+import Cell from '@/components/basic/Cell.vue';
+import Text from '@/components/basic/Text.vue';
 import Alert from '@/components/feedback/Alert.vue';
 import CheckBox from '@/components/form/CheckBox.vue';
 import CheckBoxGroup from '@/components/form/CheckBoxGroup.vue';
@@ -36,42 +81,27 @@ import { onMounted, ref, type PropType } from 'vue';
 
 export interface CheckBoxListItem {
   text: string;
+  desc?: string;
   value: any;
   disable?: boolean;
 }
 export interface CheckBoxListProps {
   multiple?: boolean,
   disabled?: boolean,
+  useCell?: boolean,
   vertical?: boolean,
   className?: string,
+  innerStyle?: Record<string, any>,
   loadData: () => Promise<CheckBoxListItem[]>;
 }
 
-const props = defineProps({
-  modelValue: {
-    type: Array as PropType<string[]>,
-    default: () => []
-  },
-  loadData: {
-    type: Function as PropType<CheckBoxListProps['loadData']>,
-    default: () => Promise.resolve([])
-  },
-  disabled: {
-    type: Boolean,
-    default: false
-  },
-  multiple: {
-    type: Boolean,
-    default: false
-  },
-  className: {
-    type: String,
-    default: ''
-  },
-  vertical: {
-    type: Boolean,
-    default: false
-  },
+const props = withDefaults(defineProps<CheckBoxListProps>(), {
+  modelValue: () => [],
+  loadData: () => Promise.resolve([]),
+  disabled: false,
+  multiple: false,
+  className: '',
+  vertical: false,
 })
 const emit = defineEmits(['update:modelValue', 'change'])
 
@@ -100,6 +130,11 @@ const reload = () => {
 }
 
 defineExpose({ reload });
+defineOptions({
+  options: {
+    virtualHost: true,
+  }
+})
 
 onMounted(() => {
   handleLoadData();

+ 14 - 9
src/components/form/CheckBox.vue

@@ -25,15 +25,19 @@
       />
     </slot>
     <slot>
-      <Text 
-        :innerStyle="{
-          ...themeStyles.checkText.value,
-          ...textStyle,
-          color: themeContext.resolveThemeColor(props.disabled === true ? disabledTextColor : textColor),
-          display: StringUtils.isNullOrEmpty(text) ? 'none' : 'flex',
-        }"
-        :text="text" 
-      />
+      <FlexCol>
+        <Text 
+          :innerStyle="{
+            ...themeStyles.checkText.value,
+            ...textStyle,
+            color: themeContext.resolveThemeColor(props.disabled === true ? disabledTextColor : textColor),
+            display: StringUtils.isNullOrEmpty(text) ? 'none' : 'flex',
+          }"
+          :text="text" 
+        />
+        <slot name="extraText">
+        </slot>
+      </FlexCol>
     </slot>
     <slot name="check" v-if="checkPosition === 'right'" icon="check" :on="value" :disabled="disabled" :shape="shape">
       <CheckBoxDefaultButton 
@@ -62,6 +66,7 @@ import type { CheckBoxGroupContextInfo } from './CheckBoxGroup.vue';
 import CheckBoxDefaultButton from './CheckBoxDefaultButton.vue';
 import Text from '../basic/Text.vue';
 import Touchable from '../feedback/Touchable.vue';
+import FlexCol from '../layout/FlexCol.vue';
 
 export interface CheckBoxProps {
   /**

+ 1 - 0
src/components/form/CheckBoxDefaultButton.vue

@@ -184,5 +184,6 @@ defineOptions({
   flex-direction: row;
   align-items: center;
   justify-content: center;
+  flex-shrink: 0;
 }
 </style>

+ 4 - 0
src/components/theme/Theme.ts

@@ -147,6 +147,10 @@ export const DefaultTheme : ThemeConfig = {
       color: 'text.content',
       fontSize: '26rpx',
     },
+    subSecondText: {
+      color: 'text.second',
+      fontSize: '26rpx',
+    },
     footerText: {
       color: 'text.second',
       fontSize: '24rpx',

+ 8 - 0
src/pages.json

@@ -207,6 +207,14 @@
       }
     },
     {
+      "path": "pages/article/correct",
+      "style": {
+        "navigationBarTitleText": "内容反馈",
+        "navigationStyle": "custom",
+        "enablePullDownRefresh": false
+      }
+    },
+    {
       "path": "pages/article/editor/editor",
       "style": {
         "navigationBarTitleText": "编辑文章",

+ 6 - 1
src/pages/article/common/DetailTabPage.vue

@@ -92,7 +92,11 @@
           </view>
           <ContentNote />
         </view>
-        <LikeFooter :content="loader.content.value" />
+        <LikeFooter :content="loader.content.value">
+          <template #left>
+            <ArticleCorrect :content="loader.content.value" />
+          </template>
+        </LikeFooter>
       </template>
     </SimplePageContentLoader>
   </view>
@@ -111,6 +115,7 @@ import { computed, type PropType, type Ref } from "vue";
 import Parse from "@/components/display/parse/Parse.vue";
 import Tabs from "@/components/nav/Tabs.vue";
 import LikeFooter from "@/pages/parts/LikeFooter.vue";
+import ArticleCorrect from "@/pages/parts/ArticleCorrect.vue";
 
 const props = defineProps({
   load: {

+ 184 - 0
src/pages/article/correct.vue

@@ -0,0 +1,184 @@
+<template>
+  <FlexCol backgroundColor="#f6f2e7">
+    <StatusBarSpace backgroundColor="transparent" />
+    <NavBar leftButton="back" />
+    <FlexCol :padding="20">
+      <FlexCol align="center" :margin="[0,0,20,0]">
+        <H3>内容反馈</H3>
+        <Text fontConfig="subText">请填写您对文章的反馈,我们会尽快处理。</Text>
+      </FlexCol>
+      <ProvideVar :vars="{
+        DynamicFormGroupBorderRadius: 20,
+        DynamicFormGroupMarginBottom: 20,
+      }">
+        <DynamicForm
+          ref="formRef"
+          :model="formModel"
+          :options="formDefine"
+        />
+      </ProvideVar>
+      <Button type="primary" @click="submit">提交</Button>
+      <XBarSpace />
+    </FlexCol>
+  </FlexCol>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { useImageSimpleUploadCo } from '@/common/components/upload/ImageUploadCo';
+import { showError } from '@/common/composeabe/ErrorDisplay';
+import { useLoadQuerys } from '@/common/composeabe/LoadQuerys';
+import Button from '@/components/basic/Button.vue';
+import Text from '@/components/basic/Text.vue';
+import DynamicForm from '@/components/dynamic/DynamicForm.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import XBarSpace from '@/components/layout/space/XBarSpace.vue';
+import H3 from '@/components/typography/H3.vue';
+import CommonContent, { FeedBackItem } from '@/api/CommonContent';
+import type { IDynamicFormOptions, IDynamicFormRef } from '@/components/dynamic';
+import type { CheckBoxListProps } from '@/components/dynamic/wrappers/CheckBoxList.vue';
+import type { FieldProps } from '@/components/form/Field.vue';
+import type { FormProps } from '@/components/form/Form.vue';
+import type { UploaderFieldProps } from '@/components/form/UploaderField.vue';
+import ProvideVar from '@/components/theme/ProvideVar.vue';
+import NavBar from '@/components/nav/NavBar.vue';
+import StatusBarSpace from '@/components/layout/space/StatusBarSpace.vue';
+import { back } from '@/components/utils/PageAction';
+
+const loading = ref(false);
+
+const formRef = ref<IDynamicFormRef>();
+const formModel = ref(new FeedBackItem());
+const formDefine : IDynamicFormOptions = {
+  formAdditionaProps: {
+    labelFlex: 0,
+    inputFlex: 1,
+    labelPosition: 'top'
+  } as FormProps,
+  formItems: [
+    {
+      name: 'c',
+      type: 'flat-group',
+      children: [
+        { 
+          label: '', 
+          name: 'type', 
+          type: 'check-box-list', 
+          defaultValue: '',
+          additionalProps: {
+            loadData: async () => {
+              return (await CommonContent.getCategoryList(335)).map((item) => ({
+                value: item.id,
+                text: item.title,
+                desc: item.intro,
+              }));
+            },
+            multiple: false,
+            vertical: true,
+            useCell: true,
+            innerStyle: { maxWidth: '680rpx' },
+          } as CheckBoxListProps,
+          rules:  [{
+            required: true,
+            message: '请选择问题类型',
+          }] 
+        },
+      ]
+    },
+    {
+      name: 'a',
+      type: 'flat-group',
+      children: [
+        { 
+          label: '问题描述', 
+          name: 'content', 
+          type: 'textarea', 
+          defaultValue: '',
+          additionalProps: {
+            placeholder: '请详细填写,描述越清晰越可帮助处理问题',
+            maxLength: 400,
+            showWordLimit: true,
+          } as FieldProps,
+          rules:  [] 
+        },
+        {
+          label: `联系方式`,
+          name: 'contact',
+          type: 'text',
+          defaultValue: '',
+          rules: [],
+          additionalProps: {
+            placeholder: '请输入您的联系电话或邮箱,我们将尽快联系您',
+          },
+        },
+      ],
+      childrenColProps: {
+        span: 24
+      }
+    },
+    {
+      name: 'b',
+      type: 'flat-group',
+      children: [
+        {
+          label: `图片上传`,
+          name: 'images',
+          type: 'uploader',
+          defaultValue: '',
+          additionalProps: {
+            upload: useImageSimpleUploadCo(),
+            maxFileSize: 1024 * 1024 * 20,
+            maxUploadCount: 9,
+          } as UploaderFieldProps,
+          rules: [],
+        },
+      ]
+    },
+  ]
+}
+
+const { querys } = useLoadQuerys({
+  contentId: 0,
+  title: '',
+  modelId: 0,
+  modelName: '',
+  mainBodyColumnId: 0,
+  mainBodyColumnName: '',
+});
+
+async function submit() {
+  if (!formRef.value)
+    return;
+  try {
+    await formRef.value.validate();
+  } catch {
+    uni.showToast({
+      title: '有必填项未填写,请检查',
+      icon: 'none',
+    });
+    return;
+  }
+  try { 
+    loading.value = true;
+    formModel.value.contentId = querys.value.contentId;
+    formModel.value.title = querys.value.title;
+    formModel.value.modelId = querys.value.modelId;
+    formModel.value.modelName = querys.value.modelName;
+    formModel.value.mainBodyColumnId = querys.value.mainBodyColumnId;
+    formModel.value.mainBodyColumnName = querys.value.mainBodyColumnName;
+    await CommonContent.feedBack(formModel.value as FeedBackItem);
+    uni.showModal({
+      title: '提交成功',
+      content: '感谢您的反馈,我们将尽快审核并通知您结果。',
+      success: () => {
+        back();
+      }
+    });
+
+  } catch (e) {
+    showError(e);
+  } finally {
+    loading.value = false;
+  }
+}
+</script>

+ 6 - 1
src/pages/article/details.vue

@@ -66,7 +66,11 @@
           </view>
 
           <ContentNote />
-          <LikeFooter :content="loader.content.value" />
+          <LikeFooter :content="loader.content.value">
+            <template #left>
+              <ArticleCorrect :content="loader.content.value" />
+            </template>
+          </LikeFooter>
         </view>
       </template>
     </SimplePageContentLoader>
@@ -93,6 +97,7 @@ import Box2LineImageRightShadow from "../parts/Box2LineImageRightShadow.vue";
 import AppCofig from "@/common/config/AppCofig";
 import LikeFooter from "../parts/LikeFooter.vue";
 import Image from "@/components/basic/Image.vue";
+import ArticleCorrect from "../parts/ArticleCorrect.vue";
 
 const loader = useSimplePageContentLoader<
   GetContentDetailItem, 

+ 25 - 0
src/pages/parts/ArticleCorrect.vue

@@ -0,0 +1,25 @@
+<template>
+  <Button icon="comment" text="内容纠错" @click="navTo('/pages/article/correct', {
+    contentId: content.id,
+    title: content.title,
+    modelId: content.modelId,
+    modelName: content.modelName,
+    mainBodyColumnId: content.mainBodyColumnId,
+    mainBodyColumnName: content.mainBodyColumnName,
+  })" />
+</template>
+
+<script setup lang="ts">
+import { type PropType } from 'vue';
+import Button from '@/components/basic/Button.vue';
+import { navTo } from '@/components/utils/PageAction';
+import type { GetContentDetailItem } from '@/api/CommonContent';
+
+defineProps({
+  content: {
+    type: Object as PropType<GetContentDetailItem>,
+    default: () => {},
+  },
+})
+
+</script>

+ 3 - 1
src/pages/parts/LikeFooter.vue

@@ -2,7 +2,7 @@
   <FlexCol position="fixed" :bottom="0" :left="0" :right="0" backgroundColor="#f5ebe0" :padding="[20,20,0,20]">
     <FlexRow justify="space-between">
       <FlexRow align="center">
-      
+        <slot name="left" />
       </FlexRow>
       <FlexRow align="center">
         <Touchable direction="row" align="center" :gap="10" :padding="[0,10]" @click="doLike">
@@ -22,6 +22,7 @@
       </FlexRow>
     </FlexRow>
     <XBarSpace />
+
   </FlexCol>
 </template>
 
@@ -36,6 +37,7 @@ import XBarSpace from "@/components/layout/space/XBarSpace.vue";
 import FlexCol from "@/components/layout/FlexCol.vue";
 import Touchable from "@/components/feedback/Touchable.vue";
 import Text from '@/components/basic/Text.vue';
+import Button from '@/components/basic/Button.vue';
 
 const props = defineProps({
   content: {

+ 6 - 1
src/pages/video/details.vue

@@ -56,7 +56,11 @@
           </view>
         </view>
         <ContentNote />
-        <LikeFooter :content="loader.content.value" />
+        <LikeFooter :content="loader.content.value">
+          <template #left>
+            <ArticleCorrect :content="loader.content.value" />
+          </template>
+        </LikeFooter>
 
       </template>
     </SimplePageContentLoader>
@@ -81,6 +85,7 @@ import { navTo } from "@/components/utils/PageAction";
 import { computed } from "vue";
 import LikeFooter from "../parts/LikeFooter.vue";
 import { onShareAppMessage, onShareTimeline } from "@dcloudio/uni-app";
+import ArticleCorrect from "../parts/ArticleCorrect.vue";
 
 const loader = useSimplePageContentLoader<
   GetContentDetailItem,