xinfeng 5 лет назад
Родитель
Сommit
1a09caebbf
47 измененных файлов с 4274 добавлено и 307 удалено
  1. 137 138
      components/SeoFooter.vue
  2. 1 1
      components/job/dealSeoFooter.js
  3. 6 6
      components/kaifain/dealSeoFooter.js
  4. 13 0
      kaifain_v2/apis/case.ts
  5. 44 0
      kaifain_v2/apis/common.ts
  6. 25 0
      kaifain_v2/apis/contact.ts
  7. 45 0
      kaifain_v2/apis/solution.ts
  8. BIN
      kaifain_v2/assets/img/logo.png
  9. 87 0
      kaifain_v2/assets/styles/buefy.scss
  10. 24 0
      kaifain_v2/assets/styles/icon.scss
  11. 115 0
      kaifain_v2/assets/styles/main.scss
  12. 13 0
      kaifain_v2/assets/styles/vars.scss
  13. 125 0
      kaifain_v2/components/Carousel.vue
  14. 183 0
      kaifain_v2/components/CategoryNav.vue
  15. 153 0
      kaifain_v2/components/Pagination.vue
  16. 170 0
      kaifain_v2/components/SearchBox.vue
  17. 126 0
      kaifain_v2/components/SolutionCell.vue
  18. 254 0
      kaifain_v2/components/Toolbar.vue
  19. 275 0
      kaifain_v2/components/Topnav.vue
  20. 490 0
      kaifain_v2/components/UserWidget.vue
  21. 2 0
      kaifain_v2/constant.ts
  22. 91 0
      kaifain_v2/helpers/seoHelper.ts
  23. 186 0
      kaifain_v2/mixins/solution.ts
  24. 267 0
      kaifain_v2/pages/index.vue
  25. 180 0
      kaifain_v2/pages/search.vue
  26. 4 0
      kaifain_v2/shims-vue.d.ts
  27. BIN
      kaifain_v2/static/favicon.ico
  28. 74 0
      kaifain_v2/store/index.ts
  29. 101 0
      kaifain_v2/utils/misc.ts
  30. 8 0
      kaifain_v2/utils/request.ts
  31. 57 0
      layouts/kaifain_v2.vue
  32. 2 2
      layouts/opacity_header.vue
  33. 11 5
      middleware/initialize.js
  34. 21 3
      nuxt.config.js
  35. 18 8
      package.json
  36. 1 1
      pages/job/index.vue
  37. 42 12
      pages/kaifain/case/_tid.vue
  38. 41 18
      pages/kaifain/detail/_tid/index.vue
  39. 2 2
      pages/otherpage/money/index.vue
  40. 6 1
      plugins/common.js
  41. 5 1
      plugins/rem.js
  42. 8 7
      plugins/router.js
  43. 68 67
      plugins/seoRouter.js
  44. 59 0
      router/index.js
  45. 10 0
      store/index.js
  46. 43 0
      tsconfig.json
  47. 681 35
      yarn.lock

+ 137 - 138
components/SeoFooter.vue

@@ -3,7 +3,7 @@
     <div id="friend-links">
       <div class="links">
         <a href="/">
-          <img class="logo" :src="baseUrl+'/Public/image/common/logo_new.png'"/>
+          <img class="logo" :src="baseUrl+'/Public/image/common/logo_new.png'" />
         </a>
         <div class="items">
           <div class="item-box" v-for="(link, index) in data.link" :key="index">
@@ -49,7 +49,7 @@
               href="http://www.beian.gov.cn/portal/registerSystemInfo?recordcode=33011002011566"
               rel="nofollow"
             >
-              <img width="20" height="20" :src="baseUrl+'/Public/image/common/badge.png'"/>
+              <img width="20" height="20" :src="baseUrl+'/Public/image/common/badge.png'" />
               <span
                 style="height:20px;line-height:20px;margin: 0px 0px 0px 5px;"
               >浙公网安备 33011002011566号</span>
@@ -62,146 +62,145 @@
 </template>
 
 <script>
-  import { mapState } from 'vuex'
-
-  export default {
-    props: {
-      data: {
-        type: Object,
-        default: {
-          baseLink: "",
-          links: []
-        }
-      }
-    },
-    computed: {
-      ...mapState(['deviceType',]),
+import { mapState } from "vuex";
+
+export default {
+  props: {
+    data: {
+      type: Object,
+      default: {
+        baseLink: "",
+        links: [],
+        link: [],
+      },
     },
-    data() {
-      return {
-        baseUrl: "",
-        jishuBaseUrl: "",
-        explan: [false, false]
+  },
+  computed: {
+    ...mapState(["deviceType"]),
+  },
+  data() {
+    return {
+      baseUrl: "",
+      jishuBaseUrl: "",
+      explan: [false, false],
+    };
+  },
+  mounted() {},
+  methods: {
+    clickMore(index) {
+      if (index > 1) {
+        return;
       }
+      this.explan[index] = !this.explan[index];
+      this.explan = [...this.explan];
     },
-    mounted() {
-
-    },
-    methods: {
-      clickMore(index) {
-        if (index > 1) {
-          return
-        }
-        this.explan[index] = !this.explan[index]
-        this.explan = [...this.explan]
-      },
-    },
-    created() {
-      this.baseUrl = this.$store.state.domainConfig.siteUrl;
-      this.jishuBaseUrl = this.$store.state.domainConfig.jishuinUrl;
-    }
-  };
+  },
+  created() {
+    this.baseUrl = this.$store.state.domainConfig.siteUrl;
+    this.jishuBaseUrl = this.$store.state.domainConfig.jishuinUrl;
+  },
+};
 </script>
 
 <style scoped>
-  #proginn-footer {
-    width: 100vw;
-  }
-
-  #friend-links,
-  #footer {
-    display: flex;
-    justify-content: center;
-    background: white;
-  }
-
-  #friend-links {
-    padding: 50px 0 40px;
-  }
-
-  .links,
-  .footer-container {
-    display: flex;
-    justify-content: space-between;
-    width: 1000px;
-    font-size: 12px;
-    color: #4a4a4a;
-  }
-
-  .logo {
-    width: 115px;
-    height: 38px;
-    margin-top: 10px;
-  }
-
-  .items {
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    width: 840px;
-  }
-
-  .item {
-    display: flex;
-  }
-
-  .name {
-    line-height: 30px;
-    font-weight: 800;
-  }
-
-  .list {
-    --height: 30px;
-    flex: 1;
-    height: var(--height);
-    line-height: var(--height);
-    overflow: hidden;
-  }
-
-  .list a {
-    display: inline-block;
-    margin: 0 15px 0 0;
-  }
-
-  .expand {
-    height: auto;
-  }
-
-  .footer-container a,
-  .list a {
-    color: #4a4a4a;
-  }
-
-  .footer-container {
-    display: flex;
-    align-items: center;
-    border-top: 1px solid #ddd;
-    padding: 20px 0 10px;
-  }
-
-  .footer-links {
-    display: flex;
-    align-items: center;
-  }
-
-  .footer-container a {
-    margin: 0 30px 0 0;
-  }
-
-  .safe {
-    text-align: right;
-  }
-
-  .safe a {
-    margin: 0;
-  }
-
-  .more {
-    line-height: 30px;
-    cursor: pointer;
-  }
-
-  .more:hover {
-    color: #1782d9;
-  }
+#proginn-footer {
+  width: 100vw;
+}
+
+#friend-links,
+#footer {
+  display: flex;
+  justify-content: center;
+  background: white;
+}
+
+#friend-links {
+  padding: 50px 0 40px;
+}
+
+.links,
+.footer-container {
+  display: flex;
+  justify-content: space-between;
+  width: 1000px;
+  font-size: 12px;
+  color: #4a4a4a;
+}
+
+.logo {
+  width: 115px;
+  height: 38px;
+  margin-top: 10px;
+}
+
+.items {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 840px;
+}
+
+.item {
+  display: flex;
+}
+
+.name {
+  line-height: 30px;
+  font-weight: 800;
+}
+
+.list {
+  --height: 30px;
+  flex: 1;
+  height: var(--height);
+  line-height: var(--height);
+  overflow: hidden;
+}
+
+.list a {
+  display: inline-block;
+  margin: 0 15px 0 0;
+}
+
+.expand {
+  height: auto;
+}
+
+.footer-container a,
+.list a {
+  color: #4a4a4a;
+}
+
+.footer-container {
+  display: flex;
+  align-items: center;
+  border-top: 1px solid #ddd;
+  padding: 20px 0 10px;
+}
+
+.footer-links {
+  display: flex;
+  align-items: center;
+}
+
+.footer-container a {
+  margin: 0 30px 0 0;
+}
+
+.safe {
+  text-align: right;
+}
+
+.safe a {
+  margin: 0;
+}
+
+.more {
+  line-height: 30px;
+  cursor: pointer;
+}
+
+.more:hover {
+  color: #1782d9;
+}
 </style>

+ 1 - 1
components/job/dealSeoFooter.js

@@ -170,7 +170,7 @@ export default class DealSeoData {
       }, {
         name: "",
         data: []
-      }, ],
+      },],
     }
     if (city && job) {
       //兼职城市&岗位页  ${jobName}兼职招聘>${cityName}${jobName}兼职招聘,并赋予对应的url

+ 6 - 6
components/kaifain/dealSeoFooter.js

@@ -19,7 +19,7 @@ export default class DealSeoData {
     this.dataList = []
     this.provinces = []
   }
-  
+
   async dealData() {
     let { name, path, params, fullPath } = this.app.context.route
     let res = await this.$axios.get('/api/kaifawu/get_options')
@@ -46,14 +46,14 @@ export default class DealSeoData {
       footer: await this.getFooterData(),
     }
   }
-  
+
   async getFooterData() {
     //设置底部link列表
     const typeList = this.typeList
     const { city, industry, techType, cityName = "", industryName = "", techTypeName = "", proviceName } = this.selected
-    const { headers: { host } } = this.req
+    const { headers: { host = '' } } = this.req || { headers: {}}
     const kaifainUrl = this.store.state.domainConfig.kaifainUrl
-    
+
     let footer = {
       baseLink: "", link: [
         { name: "", data: [] }, { name: "", data: [] },
@@ -62,7 +62,7 @@ export default class DealSeoData {
     //设置baseLink
     if (host.indexOf('local') !== -1) {
       footer.baseLink = 'http://' + host
-    } else {
+    } else if (host) {
       footer.baseLink = 'https://' + host
     }
     if (city && !industry && !techType) {
@@ -78,7 +78,7 @@ export default class DealSeoData {
     } else if (!city && industry && !techType) {
       //只有行业的
       footer.link[ 0 ].name = "其它行业领域技术解决方案"
-      
+
       footer.link[ 1 ].name = `热门城市${industryName}技术解决方案`
       footer.link[ 0 ].data = typeList.industry.map((item) => {
         return { name: `${item.name}技术解决方案`, url: `${kaifainUrl}/${item.slug}/` }

+ 13 - 0
kaifain_v2/apis/case.ts

@@ -0,0 +1,13 @@
+import request from '../utils/request'
+
+export const getCase = async (id: string) => {
+  const res = await request({
+    method: 'POST',
+    url: '/api/kaifawu/get_public_case',
+    data: {
+      id
+    }
+  })
+
+  return res.data.data
+}

+ 44 - 0
kaifain_v2/apis/common.ts

@@ -0,0 +1,44 @@
+import request from '../utils/request'
+
+export const getInitParameters = async () => {
+  const res = await request({
+    method: 'POST',
+    url: '/api/kaifain/getSearchCriteria'
+  })
+
+  return res.data.data
+}
+
+export const listBanners = async (type: number) => {
+  const res = await request({
+    method: 'POST',
+    url: '/api/kaifain/getBanner',
+    data: {
+      type
+    }
+  })
+
+  return res.data.data.list
+}
+
+export const listSearchKeywords = async () => {
+  const res = await request({
+    method: 'POST',
+    url: '/api/kaifain/getKeyWord'
+  })
+
+  return res.data.data.list
+}
+
+export const listSearchHints = async (q: string, from: string) => {
+  const res = await request({
+    method: 'POST',
+    url: '/api/kaifain/searchHint',
+    data: {
+      q,
+      from
+    }
+  })
+
+  return res.data.data
+}

+ 25 - 0
kaifain_v2/apis/contact.ts

@@ -0,0 +1,25 @@
+import request from '../utils/request'
+
+export const submitContact = async (opts: {
+  id?: string | number
+  name: string
+  phone: string
+  agent?: string
+  from: string
+}) => {
+  const { id, name, phone, agent, from } = opts
+  const res = await request({
+    method: 'POST',
+    url: '/api/user/create_not_login_user',
+    data: {
+      id,
+      providerID: id,
+      name,
+      phone,
+      agent,
+      from
+    }
+  })
+
+  return res.data
+}

+ 45 - 0
kaifain_v2/apis/solution.ts

@@ -0,0 +1,45 @@
+import request from '../utils/request'
+
+export const listSolutions = async (opts: {
+  page: number
+  size: number
+  city_id?: number | string
+  cat_id?: string
+  sort?: number
+  is_choice?: number
+  q?: string
+}) => {
+  const res = await request({
+    method: 'POST',
+    url: '/api/kaifain/getSolutionList',
+    data: opts
+  })
+
+  return res.data.data
+}
+
+export const getSolution = async (id: string) => {
+  const res = await request({
+    method: 'POST',
+    url: '/api/kaifawu/get_provider',
+    data: {
+      id
+    }
+  })
+
+  return res.data.data
+}
+
+
+export const collectSolution = async (id: string) => {
+  const res = await request({
+    method: 'POST',
+    url: '/api/collection_center/create',
+    data: {
+      item_id: id,
+      type: 2
+    }
+  })
+
+  return res.data.data
+}

BIN
kaifain_v2/assets/img/logo.png


+ 87 - 0
kaifain_v2/assets/styles/buefy.scss

@@ -0,0 +1,87 @@
+@import "./vars.scss";
+@import "~bulma/sass/utilities/_all";
+
+$fullhd: 1200px+(2 * $gap);
+$primary-invert: findColorInvert($primary);
+
+$dropdown-mobile-breakpoint: $tablet - 1;
+
+@import "~bulma";
+@import "~buefy/src/scss/buefy";
+
+html.has-navbar-fixed-top,
+body.has-navbar-fixed-top {
+  padding: 0 !important;
+}
+
+.container {
+  &.is-content {
+    max-width: 960px;
+  }
+}
+
+.input {
+  &::-webkit-input-placeholder {
+    color: rgba(64, 64, 64, 0.6);
+  }
+}
+
+.button {
+  &.is-pure {
+    background: transparent;
+    border-color: transparent;
+  }
+}
+
+.tag {
+  &.is-choice {
+    background: #d1a26d;
+    color: #fff;
+  }
+
+  &.is-border {
+    color: #555;
+    background: #fafafa;
+    box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.2);
+  }
+
+  &:not(body).is-primary.is-light {
+    background: #eaf3ff;
+    color: $primary;
+
+    &.is-border {
+      background: rgba($primary, 0.06);
+      box-shadow: inset 0 0 1px rgba(36, 135, 255, 0.4);
+    }
+  }
+}
+
+.b-checkbox.checkbox {
+  .check {
+    box-shadow: none !important;
+  }
+}
+
+.pagination {
+  .pagination-previous,
+  .pagination-next,
+  .pagination-link {
+    min-width: 2rem;
+    height: 2rem;
+  }
+
+  .pagination-link {
+    background: #f4f4f5;
+    border-color: #f4f4f5;
+    font-weight: 500;
+
+    &.is-current {
+      background: $primary;
+      border-color: $primary;
+    }
+
+    &[disabled] {
+      pointer-events: none;
+    }
+  }
+}

+ 24 - 0
kaifain_v2/assets/styles/icon.scss

@@ -0,0 +1,24 @@
+@font-face {
+  font-family: 'progico';  /* project id 1895023 */
+  src: url('//at.alicdn.com/t/font_1895023_bwo5ih2dw6m.eot');
+  src: url('//at.alicdn.com/t/font_1895023_bwo5ih2dw6m.eot?#iefix') format('embedded-opentype'),
+  url('//at.alicdn.com/t/font_1895023_bwo5ih2dw6m.woff2') format('woff2'),
+  url('//at.alicdn.com/t/font_1895023_bwo5ih2dw6m.woff') format('woff'),
+  url('//at.alicdn.com/t/font_1895023_bwo5ih2dw6m.ttf') format('truetype'),
+  url('//at.alicdn.com/t/font_1895023_bwo5ih2dw6m.svg#progico') format('svg');
+}
+
+.progico {
+  font-family: "progico" !important;
+  font-style: normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+.progico-arrow:before {
+  content: "\e601";
+}
+
+.progico-plus-o:before {
+  content: "\e719";
+}

+ 115 - 0
kaifain_v2/assets/styles/main.scss

@@ -0,0 +1,115 @@
+@import '@/kaifain_v2/assets/styles/icon.scss';
+
+.kaifain-view {
+  @import '@/kaifain_v2/assets/styles/buefy.scss';
+
+  font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Segoe UI, Arial, Roboto, PingFang SC, Hiragino Sans GB, Microsoft Yahei, sans-serif;
+  background: #fff;
+
+  .container {
+    display: block;
+  }
+
+  .navbar {
+    &>.container {
+      display: flex;
+      flex-direction: row;
+
+      @media screen and (max-width: 1023px) {
+        display: block;
+      }
+    }
+  }
+
+  .main {
+    min-width: auto;
+    min-height: auto;
+    margin: 0;
+  }
+
+  .footer {
+    padding: 0 0 1rem;
+  }
+
+  .connectUs {
+    .toastBox .toastArea {
+      .title {
+        margin: 0;
+        width: auto;
+      }
+
+      .tips {
+        font-size: 12.5px;
+        color: #999;
+        margin: 4px 0 0;
+        width: auto;
+      }
+
+      .submitBtn {
+        margin-top: 24px;
+        width: auto;
+      }
+    }
+  }
+
+  .el-dropdown-menu {
+    padding: 6px 0;
+  }
+
+  .el-dropdown-menu__item {
+    padding: 0 18px 0 16px;
+  }
+
+  #proginn-footer {
+    .item-box {
+      width: 100%;
+    }
+  }
+
+  @media screen and (max-width: 767px) {
+    .container {
+      padding-left: 16px;
+      padding-right: 16px;
+    }
+
+    .columns {
+      margin: 0;
+    }
+
+    .solution-cell {
+      margin-left: -16px;
+      margin-right: -16px;
+    }
+  }
+}
+
+.scoped-view.kaifain {
+  #proginn-header,
+  .wx-header {
+    display: none !important;
+  }
+
+  #proginn-footer {
+    .item-box {
+      width: 100%;
+    }
+  }
+
+  .topArea {
+    padding-top: 80px;
+
+    @media screen and (min-width: 768px) {
+      .topContent {
+        padding-bottom: 32px;
+      }
+    }
+  }
+
+  .navbar {
+    &,
+    &>.container,
+    & .navbar-brand {
+      min-height: auto !important;
+    }
+  }
+}

+ 13 - 0
kaifain_v2/assets/styles/vars.scss

@@ -0,0 +1,13 @@
+$primary: #2487ff;
+$ink: #272727;
+
+$tag-background-color: #f2f3f3;
+$tag-color: #3a3a3a;
+
+$checkbox-background-color: #fff;
+$checkbox-border-color: #cdcdcd;
+$checkbox-border-radius: 2px;
+$checkbox-border-width: 1px;
+
+$carousel-indicator-border: #fff;
+$carousel-indicator-color: #fff;

+ 125 - 0
kaifain_v2/components/Carousel.vue

@@ -0,0 +1,125 @@
+<template lang="pug">
+  .carousel-wrapper
+    style(type="text/css") .kaifain-view .carousel {background: {{list[curIndex] && list[curIndex].theme_color || '#eee'}}} .kaifain-view .carousel .carousel-item .text-content .button.is-primary:hover{color:{{list[curIndex] && list[curIndex].theme_color || '#20242d'}}}
+    b-carousel(
+      v-model="curIndex"
+      :arrow="false"
+      :pause-hover="false"
+      :indicator="list.length > 1"
+      indicator-style="is-lines"
+      )
+      b-carousel-item(
+        v-for="(item, idx) in list"
+        :key="idx"
+        @click="onClick(item)"
+        )
+        .pic(:style="{backgroundImage: `url('${item.image_url}')`, backgroundColor: item.theme_color || 'transparent'}")
+        .container.is-content(v-if="item.title || item.desc" :style="{textAlign: item.cont_position || 'left'}")
+          .text-content
+            .title {{item.title}}
+            .desc {{item.desc}}
+            .button.is-primary.is-inverted.is-outlined(
+              v-if="item.button_text"
+              @click.stop="onClick(item, true)"
+              ) {{item.button_text}}
+
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+
+export default Vue.extend({
+  name: 'Carousel',
+  components: {
+  },
+
+  props: {
+    list: Array
+  },
+
+  data() {
+    return {
+      curIndex: 1
+    }
+  },
+
+  methods: {
+    onClick(item, isButton = false) {
+      if (!isButton && item.button_text) {
+        return
+      }
+
+      window.open(item.jump_target)
+    }
+  }
+
+})
+</script>
+
+<style lang="scss">
+.kaifain-view .carousel {
+  width: 100%;
+  height: 400px;
+  background: #eee;
+
+  .carousel-indicator {
+    margin-bottom: 16px;
+
+    .indicator-item {
+      .indicator-style.is-lines {
+        width: 48px;
+        height: 4px;
+        opacity: 0.2;
+      }
+
+      &.is-active .indicator-style {
+        opacity: 0.8;
+      }
+    }
+  }
+
+  .carousel-item {
+    width: 100%;
+    height: 400px;
+
+    .pic {
+      background-position: center center;
+      background-repeat: no-repeat;
+      background-size: auto 100%;
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+    }
+
+    .text-content {
+      position: relative;
+      z-index: 1;
+      margin-top: 140px;
+
+      > * {
+        color: #fff;
+      }
+
+      .desc {
+        opacity: 0.76;
+      }
+
+      .button {
+        margin-top: 44px;
+        min-width: 128px;
+        height: 2.572em;
+
+        &.is-primary {
+          font-size: 0.875rem;
+
+          &:hover {
+            color: #2487ff;
+          }
+        }
+      }
+    }
+  }
+}
+</style>

+ 183 - 0
kaifain_v2/components/CategoryNav.vue

@@ -0,0 +1,183 @@
+<template lang="pug">
+  .category-nav.container.is-content
+    .columns
+      .column.label 解决方案
+      .column.tags
+        a.tag(
+          v-for="item in fullyClasses"
+          :class="topCatId === item.id ? ['is-primary', 'is-light'] : []"
+          :href="item.id !== '_' ? `/c/${item.id}` : '#'"
+          @click.stop.prevent="changeClass(item)"
+          ) {{item.name}}
+        .subnav(changeCategory
+          v-for="item in fullyClasses"
+          v-if="item.categories && item.categories.length"
+          v-show="topCatId === item.id"
+          )
+          a.tag.is-sub(
+            v-for="cat in item.categories"
+            :class="catId === cat.cat_id ? ['is-primary', 'is-light'] : []"
+            :href="cat.cat_id !== '_' ? `/c/${cat.cat_id}` : '#'"
+            @click.stop.prevent="changeCategory(item, cat)"
+            ) {{cat.alias || cat.name}}
+
+
+    .columns
+      .column.label 服务方式
+      .column.tags
+        #st-mark
+        .tag(
+          v-for="item in fullyServiceTypes"
+          :class="serviceType === item.hash_id ? ['is-primary', 'is-light'] : []"
+          @click="changeServiceType(item)"
+          ) {{item.alias || item.name}}
+
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+
+export default Vue.extend({
+  name: 'CategoryNav',
+  props: {
+    classes: {
+      type: Array,
+      default: () => [] as any[]
+    },
+    serviceTypes: {
+      type: Array,
+      default: () => [] as any[]
+    },
+    topCatId: {
+      type: String,
+      default: '_'
+    },
+    catId: {
+      type: String,
+      default: '_'
+    },
+    serviceType: {
+      type: String,
+      default: '_'
+    }
+  },
+
+  data() {
+    return {
+    }
+  },
+
+  computed: {
+    fullyClasses(): any[] {
+      return [{
+        id: '_',
+        name: '全部'
+      },
+      ...this.classes.map((row) => {
+        const { hash_id, name, categories } = row
+        const categoriesCopy = categories && categories.length ? [...categories] : []
+
+        if (categoriesCopy.length) {
+          categoriesCopy.unshift({
+            cat_id: '_',
+            name: '全部'
+          })
+        }
+
+        return {
+          id: hash_id,
+          name,
+          categories: categoriesCopy
+        }
+      })]
+    },
+
+    fullyServiceTypes(): any[] {
+      return [{
+        hash_id: '_',
+        name: '全部'
+      },
+      ...this.serviceTypes
+      ]
+    }
+  },
+
+  methods: {
+    changeClass(item) {
+      this.$emit('update:topCatId', item.id)
+      this.$emit('update:catId', '_')
+    },
+
+    changeCategory(topCat, cat) {
+      this.$emit('update:topCatId', topCat.id)
+      this.$emit('update:catId', cat.cat_id)
+    },
+
+    changeServiceType(item) {
+      this.$emit('update:serviceType', item.hash_id)
+    }
+  }
+})
+</script>
+
+<style lang="scss">
+.kaifain-view .category-nav {
+  padding: 2rem 0;
+
+  .column {
+    &.label {
+      font-size: 0.8125rem;
+      font-weight: 500;
+      flex: none;
+      margin: 4px 0 0;
+      user-select: none;
+    }
+  }
+
+  .tags {
+    &:last-child {
+      margin-bottom: -1rem;
+    }
+
+    > .tag {
+      margin-right: 0.75rem;
+    }
+  }
+
+  .tag {
+    font-size: 0.782rem;
+    min-width: 64px;
+    text-decoration: none !important;
+    cursor: pointer;
+    user-select: none;
+
+    &:not(.is-primary):hover {
+      background: #efeff0;
+    }
+
+    &.is-sub {
+      background: #f9f9fa;
+    }
+  }
+
+  .subnav {
+    background: #f9f9fa;
+    border-radius: 8px;
+    padding: 0.5rem 0.75rem 1px;
+    margin: 0.125rem 0 0.5rem;
+  }
+}
+
+.kaifain-view {
+  @media screen and (max-width: 767px) {
+    .category-nav {
+      padding: 0.75rem 0 1.5rem;
+
+      .column.label {
+        padding: 0.25rem 0.75rem 0.125rem;
+      }
+    }
+  }
+}
+</style>
+

+ 153 - 0
kaifain_v2/components/Pagination.vue

@@ -0,0 +1,153 @@
+<template lang="pug">
+  .pagenav
+    b-pagination(
+      v-if="total > 0"
+      v-model="page"
+      :total="total"
+      :per-page="perPage"
+      :range-before="isMobile ? 1 : 3"
+      :range-after="isMobile ? 2 : 5"
+      order="is-centered"
+      aria-next-label="下一页"
+      aria-previous-label="上一页"
+      aria-page-label="页"
+      aria-current-label="当前页"
+      )
+      b-pagination-button(
+        slot-scope="props"
+        :page="props.page"
+        :id="`page${props.page.number}`"
+        tag="router-link"
+        :to="mergePagelink(props.page.number)"
+        ) {{props.page.number}}
+      b-pagination-button(
+        slot="previous"
+        slot-scope="props"
+        :page="props.page"
+        tag="router-link"
+        :to="props.page.number ? mergePagelink(props.page.number) : '#'"
+        )
+        i.progico.progico-arrow.left
+      b-pagination-button(
+        slot="next"
+        slot-scope="props"
+        :page="props.page"
+        tag="router-link"
+        :to="props.page.number <= totalPage ? mergePagelink(props.page.number) : '#'"
+        )
+        i.progico.progico-arrow
+
+    .hint-msg(v-else) 无相关解决方案
+
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import qs from 'qs'
+
+export default Vue.extend({
+  name: 'Pagination',
+  components: {
+  },
+
+  props: {
+    page: {
+      type: Number,
+      default: 1
+    },
+    querystring: {
+      type: String,
+      default: ''
+    },
+    list: Array,
+    total: {
+      type: Number,
+      default: 0
+    },
+    perPage: {
+      type: Number,
+      default: 10
+    },
+    path: {
+      type: String
+    },
+    mode: {
+      type: String,
+      default: 'params'
+    }
+  },
+
+  data() {
+    return {
+      // @ts-ignore
+      isMobile: this.$deviceType.isMobile()
+    }
+  },
+
+  computed: {
+    totalPage(): number {
+      return Math.ceil(this.total / this.perPage)
+    }
+  },
+
+  methods: {
+    mergePagelink(page) {
+      let path = this.path || this.$route.path
+      const query = Object.assign({}, this.$route.query)
+
+      if (this.mode === 'params') {
+        path += page
+      }
+      else {
+        query.page = page
+      }
+
+      return `${path}${Object.keys(query).length ? '?' + qs.stringify(query) : ''}`
+
+    }
+  }
+})
+</script>
+
+<style lang="scss">
+.kaifain-view {
+  .pagenav {
+    .hint-msg {
+      text-align: center;
+      font-size: 0.875rem;
+      color: #999;
+      padding: 2rem;
+    }
+  }
+
+  .pagination {
+    max-width: 720px;
+    margin: 0 auto;
+    justify-content: center;
+    padding: 3rem 0;
+
+    .pagination-previous {
+      flex-grow: unset;
+      order: 1;
+    }
+
+    .pagination-list {
+      flex-grow: unset;
+      order: 2;
+    }
+
+    .pagination-next {
+      flex-grow: unset;
+      order: 3;
+    }
+
+    .progico-arrow {
+      font-size: 0.75em;
+
+      &.left {
+        transform: rotate(180deg);
+      }
+    }
+  }
+}
+</style>

+ 170 - 0
kaifain_v2/components/SearchBox.vue

@@ -0,0 +1,170 @@
+<template lang="pug">
+  .search-section
+    .container.is-content
+      .level.search-box
+        .level-item
+          input.input(
+            v-model="q"
+            type="text"
+            placeholder="输入行业领域、业务场景或服务类型等,搜索解决方案"
+            @keyup.enter.prevent="goSearch"
+            )
+          .button.is-primary(@click="goSearch") 搜索
+
+      .level.extension
+        .level-left
+          .hot-keywords
+            router-link.item(
+              v-for="item in hotKeywords"
+              :key="item.id"
+              :to="`/search?q=${item.word}&from=hkw`"
+              ) {{item.alias || item.word}}
+        .level-right.action
+          .button.is-pure.is-rounded(@click="onPublishClick")
+            i.progico.progico-plus-o
+            span 发布定制需求
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+
+export default Vue.extend({
+  name: 'SearchBox',
+  props: {
+    hotKeywords: {
+      type: Array,
+      default: () => []
+    }
+  },
+
+  data() {
+    return {
+      q: ''
+    }
+  },
+
+  methods: {
+    goSearch() {
+      if (!this.q) {
+        return
+      }
+      this.$router.push(`/search?q=${this.q}&from=sbl`)
+    },
+
+    onPublishClick() {
+      this.$emit('publish-click')
+    }
+  }
+})
+</script>
+
+<style lang="scss">
+.kaifain-view .search-section {
+  background: #20242d;
+  padding: 44px 0 8px;
+
+  .level {
+    margin: 0;
+  }
+
+  .search-box {
+    .input,
+    .button {
+      height: 3.25rem;
+    }
+
+    .input {
+      font-size: 0.875rem;
+      padding-left: 1.5rem;
+      padding-right: 1.5rem;
+      border-radius: 4px 0 0 4px;
+      margin: 0;
+    }
+
+    .button {
+      width: 116px;
+      font-weight: 500;
+      letter-spacing: 1px;
+      border-radius: 0 4px 4px 0;
+      flex: none;
+    }
+  }
+
+  .extension {
+    padding: 0.75rem 0;
+  }
+
+  .hot-keywords {
+    .item {
+      font-size: 0.8125rem;
+      color: rgba(255, 255, 255, 0.92);
+      margin-right: 22px;
+      display: inline-block;
+      cursor: pointer;
+
+      &::before {
+        content: '';
+        background: rgba(255, 255, 255, 0.3);
+        width: 5px;
+        height: 5px;
+        border-radius: 3px;
+        display: inline-block;
+        vertical-align: top;
+        margin: 7px 6px 0 0;
+      }
+
+      &:hover {
+        color: #fff;
+        font-weight: 500;
+      }
+    }
+  }
+
+  .action {
+    .button {
+      color: #fff;
+      font-size: 0.8125rem;
+      font-weight: 500;
+      text-align: right;
+      letter-spacing: 0.02em;
+      padding-left: 0.75em;
+      padding-right: 0.75em;
+
+      .progico-plus-o {
+        margin-right: 5px;
+      }
+
+      &:hover {
+        background: rgba(255,255,255, 0.1);
+      }
+    }
+  }
+}
+
+.kaifain-view {
+  @media screen and (max-width: 767px) {
+    .search-section {
+      padding: 44px 0 12px;
+
+      .level-right.action {
+        display: none;
+      }
+
+      .search-box {
+        .input,
+        .button {
+          height: 2.75rem;
+        }
+
+        .button {
+          width: 72px;
+        }
+      }
+
+      .extension {
+        padding: 1rem 0;
+      }
+    }
+  }
+}
+</style>

+ 126 - 0
kaifain_v2/components/SolutionCell.vue

@@ -0,0 +1,126 @@
+<template lang="pug">
+  .solution-cell
+    a.cell-block(:href="`/s/${data.hash_id}`")
+      .figure
+        img.cover(
+          :src="data.images"
+        )
+
+      .info
+        .title
+          span {{data.title}}
+          .tag.is-choice(v-if="data.is_choice == 1") 优选
+        .desc {{data.description}}
+        .provider 服务商: {{data.company_name}}
+
+    .tags
+      .tag.is-primary.is-light.is-border(v-for="item in data.cats") {{item.name}}
+      .tag.is-border(
+        v-for="item in data.tags"
+        v-if="!data.cats || !data.cats.find((cat) => cat.name === item.name)"
+        ) {{item.name}}
+
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+
+export default Vue.extend({
+  name: 'SolutionCell',
+  components: {
+  },
+
+  props: {
+    data: Object
+  }
+})
+</script>
+
+<style lang="scss">
+@import '../assets/styles/vars';
+
+.kaifain-view .solution-cell {
+  padding: 24px 24px 20px;
+  border-bottom: 1px solid #eee;
+
+  .figure {
+    margin: 4px 24px 0 0;
+    flex: none;
+
+    .cover {
+      width: 64px;
+      height: 64px;
+      display: block;
+    }
+  }
+
+  .info {
+    font-size: 12px;
+    flex: 1;
+    overflow: hidden;
+  }
+
+  .tag {
+    font-size: 0.6875rem;
+    cursor: default;
+  }
+
+  a.tag {
+    cursor: pointer;
+    text-decoration: none;
+  }
+
+  .title {
+    font-size: 1rem;
+    font-weight: 500;
+    color: $ink;
+    margin-bottom: 8px;
+
+    .tag {
+      margin-left: 8px;
+      height: 1.6em;
+      padding-left: 0.5em;
+      padding-right: 0.5em;
+    }
+  }
+
+  .desc,
+  .provider {
+    white-space: nowrap;
+    word-break: break-all;
+    text-overflow: ellipsis;
+    overflow: hidden;
+  }
+
+  .desc {
+    color: lighten($ink, 10%);
+    margin: 10px 0;
+    font-size: 0.8125rem;
+  }
+
+  .provider {
+    color: lighten($ink, 42%);
+  }
+
+  .tags {
+    padding: 2px 0;
+    margin: 10px 0 0 88px;
+  }
+
+  &.card {
+    background: #fff;
+    margin: 10px 12px;
+    border-radius: 12px;
+  }
+
+  .cell-block {
+    display: flex;
+
+    &:hover {
+      .title span {
+        color: $primary;
+      }
+    }
+  }
+}
+</style>

+ 254 - 0
kaifain_v2/components/Toolbar.vue

@@ -0,0 +1,254 @@
+<template lang="pug">
+  .toolbar
+    .container.is-content
+      .level.is-mobile
+        .level-left
+          .level-item
+            b-dropdown(
+              :triggers="[isMobile ? 'click' : 'hover']"
+              :append-to-body="isMobile"
+              )
+              .field.trigger(slot="trigger")
+                span {{sortType === 1 ? '最新发布' : '综合排序'}}
+                i.progico.progico-arrow
+              b-dropdown-item(
+                :focusable="false"
+                @click="changeSortType(0)"
+                ) 综合排序
+              b-dropdown-item(
+                :focusable="false"
+                @click="changeSortType(1)"
+                ) 最新发布
+
+          .level-item
+            b-dropdown.city-dropdown(
+              ref="city-dropdown"
+              :triggers="[isMobile ? 'click' : 'hover']"
+              :class="{'is-force-hide': forceHideCityMenu}"
+              :append-to-body="isMobile"
+              custom
+              )
+              .field.trigger(
+                slot="trigger"
+                @mouseenter="onCityTriggerHover"
+                )
+                span {{cityId && cityMap[cityId] && cityMap[cityId].name || '所在地区'}}
+                i.progico.progico-arrow
+              .city-menu
+                .row
+                  .tag.is-rounded(
+                    :class="!cityId || !cityMap[cityId] ? ['is-primary', 'is-light'] : []"
+                    @click="changeCity()"
+                    ) 不限城市
+                .split
+                .row.tags
+                  .tag.is-rounded(
+                    v-for="item in cities"
+                    :key="item.id"
+                    :class="cityId === item.id ? ['is-primary', 'is-light'] : []"
+                    @click="changeCity(item)"
+                    ) {{item.name}}
+
+          .level-item
+            .field
+              b-checkbox(
+                v-model="isChoiceCache"
+                @input="changeIsChoice"
+                ) 优选服务
+
+        .level-right
+          slot(name="right")
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+
+const enum SortType {
+  Default,
+  Recently
+}
+
+export default Vue.extend({
+  name: 'Toolbar',
+  props: {
+    cities: {
+      type: Array,
+      default: () => [] as any[]
+    },
+    sortType: {
+      type: Number,
+      default: SortType.Default
+    },
+    cityId: {
+      type: [Number, String],
+      default: null
+    },
+    isChoice: {
+      type: Boolean,
+      default: false
+    }
+  },
+
+  data() {
+    return {
+      isChoiceCache: false,
+      forceHideCityMenu: false,
+      // @ts-ignore
+      isMobile: this.$deviceType.isMobile()
+    }
+  },
+
+  computed: {
+    cityMap(): any {
+      return this.cities && this.cities.length ? this.cities.reduce((map, item) => {
+        map[item.id] = item
+        return map
+      }, {}) : {}
+    }
+  },
+
+  methods: {
+    onCityTriggerHover() {
+      this.forceHideCityMenu = false
+    },
+
+    changeSortType(type) {
+      if (type !== this.sortType) {
+        this.$emit('update:sortType', type)
+      }
+    },
+
+    changeCity(item) {
+      const id = item && item.id || null
+
+      if (id !== this.cityId) {
+        this.$emit('update:cityId', id)
+      }
+
+      if (this.isMobile) {
+        // @ts-ignore
+        this.$refs['city-dropdown'].toggle()
+      }
+
+      this.forceHideCityMenu = true
+    },
+
+    changeIsChoice(val) {
+      if (val !== this.isChoice) {
+        this.$emit('update:isChoice', val)
+      }
+    }
+  },
+
+  created() {
+    this.isChoiceCache = this.isChoice
+  }
+})
+</script>
+
+<style lang="scss">
+.kaifain-view {
+  .toolbar {
+    background: #f6f6f7;
+    height: 36px;
+
+    .level {
+      margin: 0 -0.75rem;
+    }
+
+    .level-item {
+      font-size: 0.75rem;
+      color: #777;
+      user-select: none;
+
+      .field {
+        height: 36px;
+        min-width: 86px;
+        padding: 0 0.75rem;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+
+        &.trigger:hover {
+          background: #fbfbfc;
+          color: #555;
+        }
+      }
+
+      .progico-arrow {
+        transform: rotate(90deg);
+        font-size: 10px;
+        margin: 2px 0 0 4px;
+        opacity: 0.86;
+      }
+    }
+
+    .dropdown-menu {
+      padding: 0;
+      margin-top: -4px;
+    }
+
+    .dropdown-content {
+      box-shadow: 0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1);
+      border-radius: 0;
+    }
+
+    .dropdown-item {
+      font-size: 0.75rem;
+    }
+
+    @media screen and (max-width: 767px) {
+      .level {
+        margin: 0;
+      }
+
+      .level-left {
+        flex: 1;
+      }
+
+      .level-right {
+        display: none;
+      }
+    }
+  }
+
+  .city-dropdown {
+    .city-menu {
+      width: 392px;
+      padding: 0.5rem 0 0.5rem 1rem;
+
+      .split {
+        height: 1px;
+        background: #eee;
+        margin: 0.75rem 1rem 0.75rem 0;
+      }
+
+      .tag {
+        min-width: 68px;
+        background: transparent;
+        cursor: pointer;
+
+        &:not(.is-primary):hover {
+          background: #f2f3f3;
+        }
+      }
+
+      .tags > .tag {
+        margin: 0 0.25rem 0.3125rem 0;
+      }
+    }
+
+    &.is-mobile-modal {
+      @media screen and (max-width: 767px) {
+        .city-menu {
+          width: auto;
+        }
+      }
+    }
+  }
+
+  .dropdown.is-hoverable.is-force-hide .dropdown-menu {
+    display: none;
+  }
+}
+</style>

+ 275 - 0
kaifain_v2/components/Topnav.vue

@@ -0,0 +1,275 @@
+<template lang="pug">
+  b-navbar.topnav(
+    :fixed-top="fixed"
+    :transparent="true"
+    :active.sync="isActive"
+    :class="{'is-active': isActive}"
+    wrapper-class="container"
+    )
+    template(slot="brand")
+      b-navbar-item(href="/")
+        img.logo(src="../assets/img/logo.png" alt="开发屋")
+
+    template(slot="start")
+      b-navbar-item.search-box-lite(
+        v-show="fixed"
+        tag="div"
+        )
+        b-field
+          input.input.is-small(
+            v-model="qSync"
+            placeholder="搜索服务或解决方案"
+            @keyup.enter.prevent="goSearch"
+            )
+          button.button.is-small.is-primary(@click="goSearch") 搜索
+
+    template(slot="end")
+      b-navbar-item(
+        v-show="fixed"
+        @click="onPublishClick"
+        )
+        i.progico.progico-plus-o
+        span 发布需求
+      b-navbar-item(:href="`https://${MAIN_HOST}/otherpage/companyComplete${tail}`" target="_blank") 服务商入驻
+      b-navbar-item(:href="`https://${MAIN_HOST}/cat/${tail}`" v-show="!fixed") 程序员
+      b-navbar-dropdown(
+        label="更多"
+        :hoverable="true"
+        :arrowless="true"
+        :boxed="true"
+        :collapsible="isMobile"
+        )
+        b-navbar-item(:href="`https://${MAIN_HOST}/${tail}`") 客栈首页
+        b-navbar-item(:href="`https://${MAIN_HOST}/cat/${tail}`" v-show="fixed") 程序员
+        b-navbar-item(:href="`https://job.${MAIN_HOST}/${tail}`") 兼职招聘
+        b-navbar-item(:href="`https://${MAIN_HOST}/cloud${tail}`") 云端工作
+        b-navbar-item(:href="`https://${MAIN_HOST}/type/service${tail}`") 项目开发
+        b-navbar-item(:href="`https://jishuin.${MAIN_HOST}/${tail}`") 技术圈
+      .navbar-split
+      UserWidget(v-if="myInfo && myInfo.nickname")
+      template(v-else)
+        b-navbar-item(:href="`https://${MAIN_HOST}/index/app${tail}`") APP下载
+        b-navbar-item(:href="loginUrl") 登录
+        b-navbar-item(:href="`https://${MAIN_HOST}/user/register${tail}`") 注册
+
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import { MAIN_HOST } from '../constant'
+import UserWidget from './UserWidget.vue'
+
+export default Vue.extend({
+  name: 'Topnav',
+  components: {
+    UserWidget
+  },
+  props: {
+    fixed: {
+      type: Boolean,
+      default: false
+    },
+    q: {
+      type: String,
+      default: ''
+    }
+  },
+
+  data() {
+    return {
+      MAIN_HOST: MAIN_HOST,
+      tail: '?from=kaifain',
+      qSync: this.q,
+      loginUrl: '#',
+      isActive: false,
+      // @ts-ignore
+      isMobile: this.$deviceType.isMobile()
+    }
+  },
+
+  computed: {
+    myInfo(): any {
+      return this.$store.state.userinfo
+    },
+
+    baseUrl(): string {
+      return this.$store.state.domainConfig.siteUrl
+    }
+  },
+
+  methods: {
+    goSearch() {
+      const q = this.qSync
+
+      if (!q) {
+        return
+      }
+
+      this.$router.push(`/search?q=${q}&from=sbl`)
+      this.$emit('search-change', q)
+    },
+
+    onPublishClick() {
+      this.$emit('publish-click')
+    }
+  },
+
+  mounted() {
+    this.loginUrl = this.baseUrl + '/?loginbox=show&next=' + encodeURIComponent(location.href) || ''
+  }
+})
+</script>
+
+<style lang="scss">
+$theme-color: #20242d;
+
+.kaifain-view .navbar {
+  width: 100%;
+  height: 72px;
+  padding: 12px 0;
+  color: #fff;
+  background: transparent;
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  z-index: 3;
+
+  .logo {
+    width: auto;
+    height: 44px;
+    max-height: none;
+    display: block;
+  }
+
+  .navbar-burger {
+    color: #fff;
+  }
+
+  .navbar-split {
+    background: #fff;
+    width: 1px;
+    height: 16px;
+    opacity: 0.3;
+    margin: 1px 12px 0;
+    align-self: center;
+  }
+
+  .navbar-menu {
+    &.is-active {
+      background: $theme-color;
+    }
+  }
+
+  .navbar-item,
+  .navbar-link {
+    background: transparent !important;
+    color: #fff;
+    font-size: 14px;
+  }
+
+  .navbar-dropdown {
+    > a.navbar-item {
+      color: #444;
+    }
+  }
+
+  .navbar-brand {
+    .navbar-item {
+      padding: 0;
+    }
+  }
+
+  a.navbar-item {
+    color: inherit;
+  }
+
+  .progico-plus-o {
+    margin-right: 4px;
+  }
+
+  .search-box-lite {
+    margin: 0 1px;
+
+    .input.is-small {
+      border-radius: 4px 0 0 4px;
+      min-width: 256px;
+    }
+
+    .button.is-small {
+      border-radius: 0 4px 4px 0;
+      font-weight: 500;
+      border: 0;
+    }
+  }
+
+  &.is-active,
+  &.is-fixed-top {
+    background: $theme-color;
+  }
+
+  &.is-fixed-top {
+    padding: 0;
+    height: 54px;
+
+    .logo {
+      height: 36px;
+    }
+
+    .navbar-item,
+    .navbar-link {
+      font-size: 13px;
+    }
+
+    .user-widget,
+    .user-widget .nickname {
+      font-size: 13px;
+    }
+  }
+}
+
+.kaifain-view {
+  @media screen and (max-width: 1023px) {
+    .navbar .logo {
+      height: 40px;
+    }
+
+    a.navbar-item:hover,
+    a.navbar-item.is-active {
+      background: darken(#20242d, 3%);
+      font-weight: 500;
+    }
+
+    .navbar .navbar-menu {
+      a.navbar-item {
+        color: inherit;
+      }
+
+      .navbar-split {
+        height: 1px;
+        width: auto;
+        margin: 8px 12px;
+        opacity: 0.2;
+      }
+    }
+  }
+
+  @media screen and (max-width: 767px) {
+    .navbar {
+      .navbar-burger {
+        margin-right: -16px;
+      }
+
+      .progico-plus-o {
+        display: none;
+      }
+
+      .navbar-menu {
+        &.is-active {
+          margin: 0 -16px;
+        }
+      }
+    }
+  }
+}
+</style>

+ 490 - 0
kaifain_v2/components/UserWidget.vue

@@ -0,0 +1,490 @@
+<template>
+  <div class="user-widget">
+    <el-dropdown class="nav-dropdown">
+      <el-button type="text" class="dashboard-title">
+        <i class="el-icon-tickets"></i>工作台
+      </el-button>
+      <el-dropdown-menu slot="dropdown">
+        <el-dropdown-item>
+          <a class="workstation" :href="baseUrl+'/wo/work_todo'">
+            <i class="el-icon-edit"></i>我的待办
+          </a>
+        </el-dropdown-item>
+        <el-dropdown-item>
+          <a class="workstation" :href="baseUrl+'/wo/work_platform'">
+            <i class="el-icon-date"></i>我的项目
+          </a>
+        </el-dropdown-item>
+        <el-dropdown-item>
+          <a class="workstation" :href="baseUrl+'/wo/work_hire'">
+            <i class="el-icon-news"></i>我的雇佣
+          </a>
+        </el-dropdown-item>
+        <el-dropdown-item>
+          <a class="workstation" :href="baseUrl+'/wo/work_cloud'">
+            <i class="el-icon-service"></i>我的云端
+          </a>
+        </el-dropdown-item>
+      </el-dropdown-menu>
+    </el-dropdown>
+    <el-dropdown class="nav-dropdown">
+      <el-button type="text" class="message-box-title">
+        <i class="el-icon-message"></i>消息
+        <span
+          v-if="messageCount.total > 0"
+          class="message-count message-total"
+        >{{messageCount.total}}</span>
+      </el-button>
+      <el-dropdown-menu slot="dropdown">
+        <el-dropdown-item class="message-box" @click.native="clickMessages('/message/system')">
+          <i class="circle blue"></i>系统消息
+          <span v-if="messageCount.system" class="message-count">{{messageCount.system}}</span>
+        </el-dropdown-item>
+        <el-dropdown-item class="message-box" @click.native="clickMessages('/message/project')">
+          <i class="circle orange"></i>工作通知
+          <span v-if="messageCount.work" class="message-count">{{messageCount.work}}</span>
+        </el-dropdown-item>
+        <el-dropdown-item class="message-box" @click.native="clickMessages('/message/comment')">
+          <i class="circle red"></i>评论回复
+          <span v-if="messageCount.reply" class="message-count">{{messageCount.reply}}</span>
+        </el-dropdown-item>
+        <el-dropdown-item class="message-box" @click.native="clickMessages('/message/at')">
+          <i class="circle green"></i>@我的
+          <span v-if="messageCount.at" class="message-count">{{messageCount.at}}</span>
+        </el-dropdown-item>
+        <el-dropdown-item class="message-box" @click.native="clickMessages('/message/plus')">
+          <i class="circle pink"></i>赞及其它
+          <span
+            v-if="messageCount.community_other"
+            class="message-count"
+          >{{messageCount.community_other}}</span>
+        </el-dropdown-item>
+        <el-dropdown-item class="message-box" @click.native="clickMessages('/message/coin')">
+          <i class="circle yellow"></i>收支信息
+          <span v-if="messageCount.balance" class="message-count">{{messageCount.balance}}</span>
+        </el-dropdown-item>
+      </el-dropdown-menu>
+    </el-dropdown>
+    <el-popover class="nav-popover" placement="bottom" width="226" trigger="hover">
+      <div class="ref" slot="reference">
+        <a class="nav-header" :href="baseUrl+'/wo/work_todo'">
+          <img class="header-user" :src="myInfo.icon_url" />
+          <img
+            v-if="myInfo.is_vip"
+            class="header-vip-icon"
+            :src="baseUrl+`/Public/image/h5/vip_icon${vipImage}.png`"
+            alt="vip-icon"
+          />
+        </a>
+        <span class="nickname">{{myInfo.nickname}}</span>
+      </div>
+      <div class="menu">
+        <div v-if="myInfo.is_vip" class="vip-info vip-info-com">
+          <div class="vip-info-top">
+            <img
+              class="vip-icon"
+              :src="baseUrl+`/Public/image/h5/vip_icon${vipImage}.png`"
+              alt="vip-icon"
+            />
+            <span class="vip-content">
+              <span class="vip-title" :class="vipTextClass">{{vipText}}</span>
+              <br />
+              <span class="vip-end-date">{{vipInfo.endDate}}到期</span>
+            </span>
+          </div>
+          <div class="vip-arcs">
+            <a class="vip-arc" :class="vipTextClass" :href="baseUrl+'/type/vip/'+vipType">查看权益</a>
+            <a
+              class="vip-arc"
+              :class="vipTextClass"
+              :href="baseUrl+'/vip/pay?number=3&amp;product_id='+this.$store.state.userinfo.vip_type_id+'&amp;next=/type/vip/'+vipType"
+            >立即续费</a>
+          </div>
+        </div>
+        <div class="vip-items">
+          <a class="vip-item divider" :href="baseUrl+'/wo/work_platform'">
+            <i class="el-icon-date"></i>我的项目
+          </a>
+          <a class="vip-item" :href="baseUrl+'/wo/work_hire'">
+            <i class="el-icon-news"></i>我的雇佣
+          </a>
+          <a class="vip-item" :href="baseUrl+'/wo/work_cloud'">
+            <i class="el-icon-service"></i>我的云端
+          </a>
+          <a class="vip-item divider" :href="baseUrl+`/wo/manage_homepage/`">
+            <i class="el-icon-document"></i>我的主页
+          </a>
+          <a class="vip-item" :href="baseUrl+'/credit/pages'">
+            <i class="el-icon-credit"></i>技术信用
+          </a>
+          <a class="vip-item" :href="baseUrl+'/otherpage/user/collection'">
+            <i class="el-icon-collection"></i>收藏中心
+          </a>
+          <a class="vip-item divider" :href="baseUrl+'/index/app'">
+            <i class="el-icon-download-app"></i>APP下载
+          </a>
+          <a class="vip-item" @click="clickQuit">
+            <i class="el-icon-back" style="margin: 0 10px !important;"></i>退出
+          </a>
+        </div>
+      </div>
+    </el-popover>
+  </div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+
+export default Vue.extend({
+  data() {
+    return {
+      messageCount: {}
+    }
+  },
+
+  computed: {
+    baseUrl(): string {
+      return this.$store.state.domainConfig.siteUrl
+    },
+
+    myInfo(): any {
+      return this.$store.state.userinfo
+    },
+
+    vipInfo(): any {
+      let userinfo = this.$store.state.userinfo
+      return {
+        id: userinfo.vip_type_id,
+        endDate: userinfo.vip_end_date,
+      }
+    },
+
+    vipImage(): string {
+      switch (parseInt(this.$store.state.userinfo.vip_type_id)) {
+        case 1:
+          return '_com'
+        case 2:
+          return ''
+        case 3:
+          return '_premium'
+        default:
+          return ''
+      }
+    },
+    vipType(): string | undefined {
+      switch (parseInt(this.$store.state.userinfo.vip_type_id)) {
+        case 1:
+        case 3:
+          return 'enterprise'
+        case 2:
+          return 'developer'
+      }
+    },
+    vipTextClass(): string {
+      switch (parseInt(this.$store.state.userinfo.vip_type_id)) {
+        case 1:
+          return 'is-newly'
+        case 2:
+          return 'is-dev'
+        case 3:
+          return 'is-premium'
+        default:
+          return ''
+      }
+    },
+    vipText(): string {
+      switch (parseInt(this.$store.state.userinfo.vip_type_id)) {
+        case 1:
+          return '初创版会员'
+        case 2:
+          return '开发者会员'
+        case 3:
+          return '企业版会员'
+        default:
+          return ''
+      }
+    }
+  },
+
+  methods: {
+    clickMessages(url) {
+      location.href = this.baseUrl + url
+    },
+
+    clickQuit() {
+      location.href = this.baseUrl + '/user/quit'
+    },
+
+    async getMessageCount() {
+      // @ts-ignore
+      let res = await this.$axios.$get(
+        '/api/message/getUnreadCount',
+        {},
+        { neverLogout: true }
+      )
+      if (res) {
+        this.messageCount = res.data
+      }
+    }
+  },
+
+  mounted() {
+    if (this.myInfo && this.myInfo.nickname) {
+      this.getMessageCount()
+    }
+  }
+})
+</script>
+
+<style lang="scss">
+.kaifain-view .user-widget {
+  display: flex;
+  align-items: center;
+  padding-top: 1px;
+  font-size: 14px;
+  font-weight: 400;
+
+  .el-button {
+    font-size: inherit;
+    font-weight: 400;
+    color: #444;
+  }
+
+  .el-dropdown {
+    font-size: inherit;
+  }
+
+  > .nav-dropdown {
+    margin: 0 10px;
+  }
+
+  .nav-dropdown {
+    > .el-button {
+      color: #fff;
+      transition: none;
+    }
+  }
+
+  .nav-popover {
+    font-size: inherit;
+    margin-left: 8px;
+  }
+
+  .nav-item {
+    display: flex;
+    height: 83px;
+    align-items: center;
+    font-size: 15px;
+    color: #515151;
+    /* padding: 0 15px; */
+    box-sizing: border-box;
+  }
+  .nav-item:first-child {
+    padding: 0;
+  }
+  .nav-item:nth-child(n + 2):hover {
+    color: #1782d9;
+    border-top: 5px solid transparent;
+    border-bottom: 5px solid #1782d9;
+  }
+  .nav-dropdown,
+  .nav-popover {
+    --imgWidth: 28px;
+    height: 40px;
+    display: flex;
+    align-items: center;
+  }
+  .nav-popover > .ref {
+    display: flex;
+    align-items: center;
+  }
+  .nav-header {
+    position: relative;
+    width: var(--imgWidth);
+    height: var(--imgWidth);
+    margin-right: 5px;
+  }
+  .logo {
+    width: 140px;
+    height: auto;
+  }
+  .input {
+    width: 234px;
+    height: 40px;
+    border-radius: 20px;
+    background: #f6f6f6;
+    padding: 0 40px;
+    border: 1px solid #ebebeb;
+    font-size: 13px;
+  }
+  .message-count {
+    color: white;
+    margin-left: 4px;
+    display: block;
+    line-height: 18px;
+    padding: 0 8px;
+    border-radius: 9px;
+    background: grey;
+  }
+  .message-count.message-total {
+    position: absolute;
+    top: 0px;
+    right: -10px;
+    background: #d95c5c;
+  }
+  span.other-icon {
+    display: block;
+    margin-left: 30px;
+  }
+  .nickname {
+    font-size: 14px;
+  }
+  .header-user {
+    width: var(--imgWidth);
+    height: var(--imgWidth);
+    border-radius: 20px;
+  }
+  .header-vip-icon {
+    position: absolute;
+    top: 16px;
+    left: 18px;
+    width: 16px;
+    height: 16px;
+  }
+}
+</style>
+
+<style lang="scss" scoped>
+.el-icon-credit {
+  background: url("~@/assets/img/header/creditIconMine.png") no-repeat;
+  background-size: cover;
+  width: 16px;
+  height: 16px;
+  vertical-align: middle;
+  margin: 0 9px !important;
+}
+.el-icon-download-app {
+  background: url("~@/assets/img/header/download@2x.png") no-repeat;
+  background-size: cover;
+  width: 16px;
+  height: 16px;
+  vertical-align: middle;
+  margin: 0 9px !important;
+}
+i {
+  margin-right: 4px;
+}
+i.circle {
+  display: inline-block;
+  --width: 12px;
+  width: var(--width);
+  height: var(--width);
+  border-radius: calc(var(--width) / 2);
+}
+i.blue {
+  background: #3b83c0;
+}
+i.orange {
+  background: #e07b53;
+}
+i.red {
+  background: #d95c5c;
+}
+i.green {
+  background: #5bbd72;
+}
+i.pink {
+  background: #d9499a;
+}
+i.yellow {
+  background: #f2c61f;
+}
+.message-box {
+  position: relative;
+  display: flex;
+  align-items: center;
+}
+.workstation {
+  color: #444;
+}
+.account-ctrl {
+  color: #444;
+  font-size: 15px;
+}
+
+.vip-info {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  width: 206px;
+  height: 120px;
+}
+.vip-info-top {
+  display: flex;
+  width: 142px;
+}
+.vip-title {
+  color: #cb9d53;
+  font-size: 16px;
+  line-height: 36px;
+}
+.vip-icon {
+  width: 24px;
+  height: 24px;
+  margin: 10px 8px 0 0;
+}
+.vip-end-date {
+  font-size: 12px;
+  color: #999;
+}
+.vip-arcs {
+  display: flex;
+  justify-content: space-between;
+  width: 142px;
+  margin-top: 11px;
+}
+.vip-arc {
+  flex: 1;
+  font-size: 13px;
+  color: #cb9d53;
+  text-align: center;
+}
+.vip-arc:first-child {
+  border-right: 1px solid rgba(245, 245, 245, 1);
+}
+.vip-info-com .vip-title {
+  color: rgb(113, 177, 253);
+}
+.vip-info-com .vip-arc {
+  color: rgb(113, 177, 253);
+}
+.vip-items {
+  font-size: 14px;
+  display: flex;
+  flex-direction: column;
+}
+.vip-item {
+  line-height: 32px;
+  color: #606266;
+}
+.vip-item > i {
+  margin: 0 10px;
+}
+.divider {
+  border-top: 1px solid rgba(0, 0, 0, 0.05);
+  margin-top: 10px;
+  padding-top: 10px;
+  cursor: pointer;
+}
+.vip-info-com .is-dev {
+  color: #cb9d53;
+}
+.vip-info-com .is-newly {
+  color: #308eff;
+}
+
+.vip-info-com .is-premium {
+  color: #00c469;
+}
+</style>

+ 2 - 0
kaifain_v2/constant.ts

@@ -0,0 +1,2 @@
+export const MAIN_HOST = 'proginn.com'
+export const KAIFAIN_ENDPOINT = process.env.NODE_ENV === 'development' ? 'https://web.test.proginn.com' : 'https://proginn.com'

+ 91 - 0
kaifain_v2/helpers/seoHelper.ts

@@ -0,0 +1,91 @@
+import { randomRange, log10 } from '../utils/misc'
+import murmurhash from 'murmurhash'
+
+export const genDocumentHeadData = (input: {
+  ctx: any
+  catName?: string
+}) => {
+  const { ctx, catName } = input || {}
+  const canonical: string = process.server ? `https://${ctx.req.headers.host}${ctx.req.url}` : location && location.href
+  let title: string
+  let keywords: string
+  let descrption: string
+
+  if (catName) {
+    title = `${catName} 软件、网站、APP、小程序开发及SaaS、PaaS、IaaS、API服务平台-开发屋`
+    keywords = `${catName}软件开发,${catName}APP开发,${catName}小程序开发,${catName}SaaS服务商`
+    descrption = `开发屋为${catName}中小型企业企业提供各行业软件、APP、小程序开发服务,并整合开发项目所需的各种SaaS、PaaS,LaaS以及API服务商供选择,全程解决开发需求!`
+  } else {
+    title = '开发屋-提供网站建设、APP软件、小程序开发及SaaS、PasS、IaaS服务'
+    keywords = '网站开发, 软件APP开发, SaaS, PaaS'
+    descrption = '开发屋为企业提供行业内领先的技术解决方案,包括行业定制化SaaS、PasS、API数据接口服务以及技术组织,保障企业在降低人力开发成本的同时,得到优质的项目开发实力。'
+  }
+
+  return {
+    title,
+    keywords,
+    descrption,
+    canonical
+  }
+}
+
+const scoreSubCatItem = ({path, topCatId, subCatId}) => {
+  const uni = murmurhash.v3(path)
+  const isRoot = path === '/'
+
+  return (item: any, idx: number) => {
+    let score = 0
+
+    if (topCatId && item.parent_id === topCatId) {
+      score += 100
+    }
+
+    if (subCatId && item.cat_id === subCatId) {
+      score -= 100
+    }
+
+    if (!isRoot && !score) {
+      score += log10(Math.abs(uni - murmurhash.v3(item.cat_id)))
+    }
+
+    return Object.assign({}, item, {
+      _score: score
+    })
+  }
+}
+
+const genFooterLinkItem = (name: string, hashId: string) => {
+  return {
+    name: `${name}技术开发`,
+    url: `/c/${hashId}`
+  }
+}
+
+export const genDocumentFooterData = (input: {
+  ctx: any
+  classes: any[]
+  topCatId?: string
+  subCatId?: string
+}) => {
+  const { ctx, classes, topCatId, subCatId } = input || {}
+  const host = (process.server ? ctx.req.headers.host : location.host) || ''
+  const path = process.server ? ctx.req.url : location.pathname || '/'
+  const baseLink = host && host.indexOf('local') > -1 ? `http://${host}` : `https://${host}`
+  const link = [{
+    name: '热门领域技术解决方案',
+    data: classes.map(topCat => genFooterLinkItem(topCat.name, topCat.hash_id))
+  }, {
+    name: '细分领域技术解决方案',
+    data: classes
+      .flatMap(topCat => topCat.categories || [])
+      .map(scoreSubCatItem({path, topCatId, subCatId}))
+      .sort((a, b) => b._score - a._score)
+      .slice(0, randomRange(20, 30))
+      .map(cat => genFooterLinkItem(cat.name, cat.cat_id))
+  }]
+
+  return {
+    baseLink,
+    link
+  }
+}

+ 186 - 0
kaifain_v2/mixins/solution.ts

@@ -0,0 +1,186 @@
+import Vue from 'vue'
+import { listSolutions } from '../apis/solution'
+import { genCatIdSearchSentence, getTopCatSubCatIds } from '../utils/misc'
+
+export const SolutionListMixin = Vue.extend({
+  data() {
+    return {
+      q: '',
+      page: 0,
+      size: 10,
+      solutionList: [],
+      solutionTotal: 0,
+      cityId: null,
+      sortType: 0,
+      isChoice: false,
+      topCatId: '_',
+      catId: '_',
+      serviceType: '_',
+      classes: [] as any[]
+    }
+  },
+
+  watch: {
+    topCatId(val) {
+      if (this.catId === '_') {
+        this.updateUrlPage(1, {
+          path: val !== '_' ? `/c/${val}` : '/',
+          params: {
+            cat_id: val
+          },
+          reset: true
+        })
+      }
+    },
+
+    catId(val) {
+      val = val !== '_' ? val : this.topCatId
+
+      this.updateUrlPage(1, {
+        path: val !== '_' ? `/c/${val}` : '/',
+        params: {
+          cat_id: val
+        },
+        reset: true
+      })
+    },
+
+    serviceType(val) {
+      this.getSolutionList({
+        query: {
+          st: val !== '_' ? val : undefined
+        }
+      })
+    },
+
+    cityId(val) {
+      this.getSolutionList({
+        query: {
+          city_id: val || undefined
+        }
+      })
+    },
+
+    sortType(val) {
+      this.getSolutionList({
+        query: {
+          sort: val || undefined
+        }
+      })
+    },
+
+    isChoice(val) {
+      this.getSolutionList({
+        query: {
+          is_choice: val ? 1 : undefined
+        }
+      })
+    }
+  },
+
+  methods: {
+    updateUrlPage(targetPage = 1, input: {
+      path?: string
+      params?: any
+      query?: any
+      reset?: boolean
+    }) {
+      const paramsPage = this.$route.params.page
+      const queryPage = this.$route.query.page
+      const curPage = ~~(paramsPage || queryPage)
+      let { path, params, query, reset } = input
+
+      if ((!curPage || curPage === targetPage) && !params && !query) {
+        return
+      }
+
+      params = params || {}
+      query = query || {}
+
+      const opts: any = {
+        path,
+        params: Object.assign({}, this.$route.params, params),
+        query: Object.assign({}, this.$route.query, query)
+      }
+
+      if (paramsPage || params) {
+        Object.assign(opts.params, {
+          page: targetPage
+        })
+      }
+
+      if (queryPage) {
+        Object.assign(opts.query, {
+          page: targetPage
+        })
+      }
+
+      if (reset) {
+        delete opts.query.sort
+        delete opts.query.city_id
+        delete opts.query.is_choice
+        delete opts.query.st
+      }
+
+      this.$router.replace(opts)
+    },
+
+    async getSolutionList(opts: {
+      page?: number
+      path?: string
+      params?: any
+      query?: any
+      reset?: boolean
+      updateUrl?: boolean
+    }) {
+      const {
+        page = 1,
+        path,
+        params = false,
+        query = false,
+        reset = false,
+        updateUrl = true
+      } = opts
+
+      if (reset) {
+        this.sortType = 0
+        this.cityId = null
+        this.isChoice = false
+        this.serviceType = '_'
+      }
+
+      if (updateUrl) {
+        this.updateUrlPage(page, {
+          path,
+          params,
+          query,
+          reset
+        })
+      }
+
+      let matchedCatIds: any[] = []
+
+      if (this.catId === '_') {
+        const topCat = this.classes.find((topCat) => topCat.hash_id === this.topCatId)
+
+        matchedCatIds = getTopCatSubCatIds(topCat)
+      } else {
+        matchedCatIds = [this.catId]
+      }
+
+      const solutionRes = await listSolutions({
+        cat_id: genCatIdSearchSentence(matchedCatIds, this.serviceType),
+        city_id: this.cityId || '',
+        sort: this.sortType || 0,
+        is_choice: this.isChoice && 1 || undefined,
+        q: this.q,
+        page,
+        size: 10
+      })
+
+      this.page = page
+      this.solutionList = solutionRes && solutionRes.list || []
+      this.solutionTotal = solutionRes && ~~solutionRes.total
+    }
+  }
+})

+ 267 - 0
kaifain_v2/pages/index.vue

@@ -0,0 +1,267 @@
+<template lang="pug">
+  .view.home
+    Topnav(
+      :fixed="topnavFixed"
+      @publish-click="connectPopupVisible = true"
+      )
+    Carousel(:list="bannerList")
+    SearchBox(
+      :hot-keywords="hotKeywords"
+      @publish-click="connectPopupVisible = true"
+      )
+    CategoryNav(
+      :classes="classes"
+      :service-types="serviceTypes"
+      :top-cat-id.sync="topCatId"
+      :cat-id.sync="catId"
+      :service-type.sync="serviceType"
+      )
+    .main
+      Toolbar(
+        :cities="cities"
+        :city-id.sync="cityId"
+        :sort-type.sync="sortType"
+        :is-choice.sync="isChoice"
+        )
+        .result-msg(
+          v-if="!isMobile && (catName || topCatName)"
+          slot="right"
+          )
+          h1 {{(catName || topCatName).replace(/方案$/, '')}}解决方案
+          .total  ({{solutionList.length}})
+      #solution-list.list.container.is-content
+        SolutionCell(
+          v-for="row in solutionList"
+          :key="row.hash_id"
+          :data="row"
+          )
+      Pagination(
+        path="/page/"
+        mode="params"
+        :page="page || 1"
+        :total="solutionTotal"
+        )
+
+    KaifainFooter(:data="footer")
+    ConnectUs(
+      source="开发屋"
+      :isShowToast="connectPopupVisible"
+      @close="connectPopupVisible = false"
+      )
+
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import Topnav from '../components/Topnav.vue'
+import Carousel from '../components/Carousel.vue'
+import SearchBox from '../components/SearchBox.vue'
+import CategoryNav from '../components/CategoryNav.vue'
+import Toolbar from '../components/Toolbar.vue'
+import SolutionCell from '../components/SolutionCell.vue'
+import Pagination from '../components/Pagination.vue'
+import { listBanners, listSearchKeywords, getInitParameters } from '../apis/common'
+import { listSolutions } from '../apis/solution'
+import { SolutionListMixin } from '../mixins/solution'
+import { parseCatIdAndServiceType, genCatIdSearchSentence, scrollToElement } from '../utils/misc'
+import { genDocumentHeadData, genDocumentFooterData } from '../helpers/seoHelper'
+
+import ConnectUs from '@/components/common/connectUsKaifain.vue'
+import KaifainFooter from '@/components/SeoFooter.vue'
+
+export default SolutionListMixin.extend({
+  name: 'Home',
+  components: {
+    Topnav,
+    Carousel,
+    SearchBox,
+    CategoryNav,
+    Toolbar,
+    SolutionCell,
+    Pagination,
+    ConnectUs,
+    KaifainFooter
+  },
+
+  scrollToTop: false,
+  layout: 'kaifain_v2',
+
+  data() {
+    return {
+      topnavFixed: false,
+      connectPopupVisible: false,
+      topCatName: '',
+      catName: '',
+      // @ts-ignore
+      isMobile: this.$deviceType.isMobile()
+    }
+  },
+
+  head() {
+    const { title, keywords, descrption, canonical } = genDocumentHeadData({
+      ctx: this.$ssrContext,
+      catName: this.catName || this.topCatName
+    })
+
+    return {
+      title,
+      meta: [{
+        name: 'keywords',
+        content: keywords,
+      },
+      {
+        name: 'descrption',
+        content: descrption
+      }],
+      link: canonical ? [{
+        rel: 'canonical',
+        href: canonical
+      }] : undefined
+    }
+  },
+
+  async asyncData(ctx: any) {
+    const { app, store, params, query, error } = ctx
+    let {
+      page = 0,
+      cat_id,
+      pathMatch
+    } = params as any
+    const {
+      sort = 0,
+      city_id,
+      is_choice,
+      st
+    } = query as any
+
+    // TODO: temporarily fixed
+    if (pathMatch && (!page || !cat_id)) {
+      const matchedPage = /^\/page\/(\d+)$/.exec(pathMatch)
+      const matchedCatId = !matchedPage && /^\/c\/([0-9a-z]{8,12})$/.exec(pathMatch)
+
+      if (matchedPage) {
+        page = ~~matchedPage[1]
+      } else if (matchedCatId) {
+        cat_id = matchedCatId[1]
+      }
+    }
+
+    if (!store.state.kaifain.inited) {
+      await store.dispatch('parameters:load')
+      await store.dispatch('banners:load')
+      await store.dispatch('hotKeywords:load')
+    }
+
+    const {
+      cities,
+      classes,
+      serviceTypes,
+      bannerList,
+      hotKeywords
+    } = store.state.kaifain
+
+    const {
+      topCatId,
+      topCatName,
+      catId,
+      catName,
+      serviceType,
+      matchedCatIds
+    } = parseCatIdAndServiceType({
+      cat_id,
+      st,
+      classes,
+      serviceTypes
+    })
+
+    const solutionRes = await listSolutions({
+      cat_id: genCatIdSearchSentence(matchedCatIds, serviceType),
+      sort,
+      city_id,
+      is_choice: is_choice ? 1 : undefined,
+      page: ~~page || 1,
+      size: 10
+    })
+
+    if (!solutionRes) {
+      throw error({ statusCode: 404, message: 'Post not found (no data)' })
+    }
+
+    const footer = genDocumentFooterData({
+      ctx,
+      classes,
+      topCatId,
+      subCatId: catId
+    })
+
+    return {
+      page: ~~page,
+      sortType: ~~sort,
+      topCatId,
+      topCatName,
+      catId,
+      catName,
+      serviceType,
+      cityId: city_id || null,
+      isChoice: !!is_choice || false,
+      bannerList,
+      hotKeywords,
+      cities,
+      classes,
+      serviceTypes,
+      solutionList: solutionRes.list || [],
+      solutionTotal: ~~solutionRes.total,
+      footer
+    }
+  },
+
+  methods: {
+    onScroll() {
+      // @ts-ignore
+      this.topnavFixed = window.scrollY > 560
+    }
+  },
+
+  mounted() {
+    // @ts-ignore
+    window.addEventListener('scroll', this.onScroll)
+
+    // @ts-ignore
+    if (this.page) {
+      this.$nextTick(() => {
+        scrollToElement('#st-mark')
+      })
+    }
+  }
+})
+</script>
+
+<style lang="scss">
+.kaifain-view {
+  .home {
+    .main {
+      min-height: 480px;
+
+      .result-msg {
+        color: #777;
+        font-size: 0.75rem;
+        font-weight: 400;
+        margin: 0 1rem 0 0;
+
+        > * {
+          display: inline-block;
+        }
+
+        .total {
+          margin-left: 4px;
+        }
+      }
+
+      h1 {
+        font-size: inherit;
+        font-weight: inherit;
+      }
+    }
+  }
+}
+</style>

+ 180 - 0
kaifain_v2/pages/search.vue

@@ -0,0 +1,180 @@
+<template lang="pug">
+  .view.search-result#view
+    Topnav(
+      :q="q"
+      :fixed="topnavFixed"
+      @search-change="onSearchChange"
+      @publish-click="connectPopupVisible = true"
+      )
+    .main
+      Toolbar(
+        :cities="cities"
+        :city-id.sync="cityId"
+        :sort-type.sync="sortType"
+        :is-choice.sync="isChoice"
+        )
+      .container.is-content
+        .hits-msg 为您找到 {{solutionTotal}} 个 "{{q}}" 解决方案
+      #solution-list.list.container.is-content
+        SolutionCell(
+          v-for="row in solutionList"
+          :key="row.hash_id"
+          :data="row"
+          )
+      Pagination(
+        mode="query"
+        :page="page || 1"
+        :total="solutionTotal"
+        )
+
+    KaifainFooter(:data="footer")
+    ConnectUs(
+      source="开发屋-搜索"
+      :isShowToast="connectPopupVisible"
+      @close="connectPopupVisible = false"
+      )
+
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import Topnav from '../components/Topnav.vue'
+import Carousel from '../components/Carousel.vue'
+import SearchBox from '../components/SearchBox.vue'
+import CategoryNav from '../components/CategoryNav.vue'
+import Toolbar from '../components/Toolbar.vue'
+import SolutionCell from '../components/SolutionCell.vue'
+import Pagination from '../components/Pagination.vue'
+import { listBanners, listSearchKeywords, getInitParameters } from '../apis/common'
+import { listSolutions } from '../apis/solution'
+import { SolutionListMixin } from '../mixins/solution'
+import { scrollToElement } from '../utils/misc'
+import { genDocumentFooterData } from '../helpers/seoHelper'
+
+import ConnectUs from '@/components/common/connectUsKaifain.vue'
+import KaifainFooter from '@/components/SeoFooter.vue'
+
+export default SolutionListMixin.extend({
+  name: 'Search',
+  components: {
+    Topnav,
+    Carousel,
+    SearchBox,
+    CategoryNav,
+    Toolbar,
+    SolutionCell,
+    Pagination,
+    ConnectUs,
+    KaifainFooter
+  },
+
+  layout: 'kaifain_v2',
+
+  head() {
+    return {
+      title: `${this.q} - 开发屋搜索`
+    }
+  },
+
+  data() {
+    return {
+      topnavFixed: true,
+      connectPopupVisible: false
+    }
+  },
+
+  watch: {
+    ['$route.query.page'](val) {
+      if (val) {
+        this.getSolutionList({
+          page: ~~val,
+          updateUrl: false
+        })
+          .then(() => {
+            scrollToElement('#view')
+          })
+      }
+    }
+  },
+
+  async asyncData(ctx: any) {
+    const { store, query, error } = ctx
+    const {
+      q = '',
+      page = 0,
+      sort = 0,
+      city_id,
+      is_choice
+    } = query as any
+
+    if (!store.state.kaifain.inited) {
+      await store.dispatch('parameters:load')
+    }
+
+    const { cities, classes, serviceTypes } = store.state.kaifain
+    const solutionRes = await listSolutions({
+      cat_id: undefined,
+      q,
+      sort,
+      city_id,
+      is_choice: is_choice ? 1 : undefined,
+      page: ~~page || 1,
+      size: 10
+    })
+
+    if (!solutionRes) {
+      throw error({ statusCode: 404, message: 'Post not found (no data)' })
+    }
+
+    const footer = genDocumentFooterData({
+      ctx,
+      classes
+    })
+
+    return {
+      q,
+      page: ~~page,
+      sortType: ~~sort,
+      cityId: city_id || null,
+      isChoice: !!is_choice || false,
+      cities,
+      classes,
+      serviceTypes,
+      solutionList: solutionRes.list || [],
+      solutionTotal: ~~solutionRes.total,
+      footer
+    }
+  },
+
+  methods: {
+    async onSearchChange(val) {
+      if (!val) {
+        return
+      }
+
+      this.q = val
+      await this.getSolutionList({
+        reset: true
+      })
+    }
+  }
+})
+</script>
+
+<style lang="scss">
+.kaifain-view {
+  .search-result {
+    .main {
+      padding-top: 54px;
+      min-height: 720px;
+    }
+
+    .hits-msg {
+      color: #444;
+      font-size: 0.8125rem;
+      font-weight: 500;
+      padding: 1.25rem 0.75rem 0.75rem;
+    }
+  }
+}
+</style>

+ 4 - 0
kaifain_v2/shims-vue.d.ts

@@ -0,0 +1,4 @@
+declare module '*.vue' {
+  import Vue from 'vue';
+  export default Vue;
+}

BIN
kaifain_v2/static/favicon.ico


+ 74 - 0
kaifain_v2/store/index.ts

@@ -0,0 +1,74 @@
+import { listBanners, listSearchKeywords, getInitParameters } from '../apis/common'
+
+export const state = () => ({
+  inited: false,
+  cities: [],
+  classes: [],
+  serviceTypes: [],
+  bannerList: [],
+  hotKeywords: []
+})
+
+export const actions = {
+  async ['parameters:load']({ commit }) {
+    commit('parameters:sync', await getInitParameters())
+  },
+
+  async ['banners:load']({ commit }) {
+    commit('banners:sync', await listBanners(1))
+  },
+
+  async ['hotKeywords:load']({ commit }) {
+    commit('hotKeywords:sync', await listSearchKeywords())
+  }
+}
+
+export const mutations = {
+  ['parameters:sync'](state, { cities, classes, serviceTypes }) {
+    if (cities) {
+      state.cities = cities
+    }
+
+    if (classes) {
+      state.classes = classes
+      state.fullyClasses = [{
+        id: '_',
+        name: '全部'
+      },
+      ...classes.map((row) => {
+        const { hash_id, name, categories } = row
+        const categoriesCopy = categories && categories.length ? [...categories] : []
+
+        if (categoriesCopy.length) {
+          categoriesCopy.unshift({
+            cat_id: '_',
+            name: '全部'
+          })
+          categoriesCopy.forEach((cat) => {
+            cat.parent_id = hash_id
+          })
+        }
+
+        return {
+          id: hash_id,
+          name,
+          categories: categoriesCopy
+        }
+      })]
+    }
+
+    if (serviceTypes) {
+      state.serviceTypes = serviceTypes
+    }
+
+    state.inited = true
+  },
+
+  ['banners:sync'](state, data) {
+    state.bannerList = data
+  },
+
+  ['hotKeywords:sync'](state, data) {
+    state.hotKeywords = data
+  }
+}

+ 101 - 0
kaifain_v2/utils/misc.ts

@@ -0,0 +1,101 @@
+export const randomRange = (min: number, max: number) => {
+  return Math.random() * (max - min) + min
+}
+
+export const log10 = (val: number) => {
+  return Math.log(val) / 10
+}
+
+export const scrollToElement = (selector: string) => {
+  const el = document.querySelector(selector)
+
+  if (el) {
+    setTimeout(() => {
+      el.scrollIntoView({
+        behavior: 'smooth'
+      })
+    }, 17)
+  }
+}
+
+export const findTopCatBySubCatId = (classes: any[], catId: string): any[] | null => {
+  for (const topCat of classes) {
+    const cats = topCat.categories
+    const matchedCat = cats && cats.length && cats.find((cat) => cat.cat_id === catId)
+
+    if (matchedCat) {
+      return [topCat, matchedCat]
+    }
+  }
+
+  return null
+}
+
+export const getTopCatSubCatIds = (topCat) => {
+  return topCat && topCat.categories && topCat.categories.map((cat) => cat.cat_id) || []
+}
+
+export const parseCatIdAndServiceType = (input: {
+  cat_id: string
+  st: string
+  classes: any[]
+  serviceTypes: any[]
+}) => {
+  const {
+    cat_id,
+    st,
+    classes = [],
+    serviceTypes = []
+  } = input
+
+  let topCatId = '_'
+  let catId = '_'
+  let serviceType = '_'
+  let matchedCatIds: string[] = []
+  let topCatName!: string
+  let catName!: string
+
+  if (cat_id) {
+    let topCat
+    let cat
+
+    if (cat_id.length === 8) {
+      topCat = classes.find((topCat) => topCat.hash_id === cat_id)
+      matchedCatIds = getTopCatSubCatIds(topCat)
+    } else {
+      [topCat, cat] = findTopCatBySubCatId(classes, cat_id) || []
+
+      if (cat) {
+        catId = cat_id
+        catName = cat.name
+        matchedCatIds = [catId]
+      }
+    }
+
+    if (topCat) {
+      topCatId = topCat.hash_id
+      topCatName = topCat.name
+    }
+  }
+
+  if (st) {
+    const matchedServiceType = serviceTypes.find((item) => item.hash_id === st)
+
+    if (matchedServiceType) {
+      serviceType = st
+    }
+  }
+
+  return {
+    topCatId,
+    topCatName,
+    catId,
+    catName,
+    serviceType,
+    matchedCatIds
+  }
+}
+
+export const genCatIdSearchSentence = (catIds: string[], serviceType: string) => {
+  return [catIds.join('|'), serviceType].filter((val) => !!val && val != '_').join('&&')
+}

+ 8 - 0
kaifain_v2/utils/request.ts

@@ -0,0 +1,8 @@
+import { ProginnRequest } from 'proginn-lib'
+import { KAIFAIN_ENDPOINT } from '../constant'
+
+const request = ProginnRequest.create({
+  baseURL: KAIFAIN_ENDPOINT
+})
+
+export default request

+ 57 - 0
layouts/kaifain_v2.vue

@@ -0,0 +1,57 @@
+<template>
+  <nuxt />
+</template>
+
+<script>
+import Cookies from 'js-cookie'
+
+const MOB_HOST = process.env.NODE_ENV === 'development' ? 'kaifain-m.test.proginn.com' : 'kaifain.m.proginn.com'
+const REM_RESET_STYLE = `font-size:16px !important;`
+
+const resetRem = () => {
+  const _rem_mark = document.querySelector('#rootsize')
+
+  if (_rem_mark) {
+    _rem_mark.innerHTML = `html{${REM_RESET_STYLE}}`
+    document.documentElement.style = REM_RESET_STYLE
+    document.body.style = REM_RESET_STYLE
+  }
+}
+
+export default {
+  head() {
+    return {
+      bodyAttrs: {
+        class: ['kaifain-view', 'non-rem']
+      }
+    }
+  },
+
+  created() {
+    if (process.client) {
+      if (Cookies.get('x_app')) {
+        const source = window.location.href
+        let target
+
+        if (/\/(s|d)\/[0-9a-f]+/.test(source)) {
+          target = source.replace(location.host, MOB_HOST)
+        } else {
+          target = `https://${MOB_HOST}/`
+        }
+
+        window.location.replace(target)
+      }
+    }
+  },
+
+  beforeMount() {
+    resetRem()
+  }
+}
+</script>
+
+<style lang="scss">
+:root {
+  font-size: 16px;
+}
+</style>

+ 2 - 2
layouts/opacity_header.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="container" id="markIsAppWebview" :data-app="deviceType.app">
+  <div class="container" id="markIsAppWebview" :data-app="deviceType.app" :class="scope ? ['scoped-view', scope] : []">
     <proginn-header v-if="deviceType.pc" transparent="true" />
     <wx-header v-else-if="!deviceType.app"></wx-header>
     <nuxt class="main" />
@@ -20,7 +20,7 @@ export default {
     WxHeader
   },
   computed: {
-    ...mapState(["isPC", "isWeixin", "deviceType", "noneCommonFooter"])
+    ...mapState(["isPC", "isWeixin", "deviceType", "noneCommonFooter", "scope"])
   },
   mounted() {
     console.log("this****", this);

+ 11 - 5
middleware/initialize.js

@@ -177,6 +177,12 @@ export default function (context) {
   context.store.commit("updateDeviceType", context.app.$deviceType || {});
   context.store.commit("updateIsPC", context.app.$deviceType.pc || false);
   context.store.commit("updateIsWeixin", context.app.$deviceType.wx || false);
+
+  const host = process.server ? context.req.headers.host : location.host
+  const matchedScope = host && /(kaifain|jishuin|job)/.exec(host)
+
+  matchedScope && context.store.commit('scope:set', matchedScope[1])
+
   // 301
   // Server-side
   if (process.server) {
@@ -194,10 +200,10 @@ export default function (context) {
       redirect(301, kaifainUrl + reqUrl.pathname.replace('/kaifain/', '/') + reqUrl.search)
     }
     // console.log("server", req.headers)
-  
-      console.log('****** 0000000000000 ******')
-      console.log(context.app.router)
-      console.log(context.app.router.options.routes)
-      console.log('****** 11111111111111 ******')
+
+    // console.log('****** 0000000000000 ******')
+    // console.log(context.app.router)
+    // console.log(context.app.router.options.routes)
+    // console.log('****** 11111111111111 ******')
   }
 }

+ 21 - 3
nuxt.config.js

@@ -49,7 +49,8 @@ module.exports = {
    */
   build: {
     extractCSS: true,
-    resourceHints: true
+    resourceHints: true,
+    transpile: ['proginn-lib']
   },
   hooks: {
     // This hook is called before rendering the html to the browser
@@ -130,7 +131,8 @@ module.exports = {
   css: [
     "@/assets/css/common.css",
     "@/assets/css/special.css",
-    "swiper/dist/css/swiper.css"
+    "swiper/dist/css/swiper.css",
+    "@/kaifain_v2/assets/styles/main.scss"
   ],
 
   /*
@@ -236,10 +238,26 @@ module.exports = {
     ]
   ],
 
+  buildModules: [
+    '@nuxt/typescript-build',
+    ['@nuxtjs/router', {
+      path: 'router',
+      fileName: 'index.js',
+      keepDefaultRouter: true,
+    }]
+  ],
+
   /*
    ** Nuxt.js modules
    */
-  modules: ["@nuxtjs/axios", "@nuxtjs/proxy"],
+  modules: [
+    "@nuxtjs/axios",
+    "@nuxtjs/proxy",
+    ["nuxt-buefy", {
+      css: false,
+      materialDesignIcons: false
+    }]
+  ],
   router: {
     middleware: ["initialize"],
     ...seoRouter

+ 18 - 8
package.json

@@ -6,17 +6,19 @@
   "private": true,
   "scripts": {
     "xf": "rm -rf .nuxt && npm rebuild node-sass && npm run dev",
-    "build_release": "cross-env nuxt build",
-    "dev": "cross-env nuxt",
-    "build": "cross-env nuxt build",
-    "start": "cross-env PORT=3000 nuxt start",
-    "generate": "cross-env nuxt generate"
+    "build_release": "cross-env nuxt-ts build",
+    "dev": "cross-env nuxt-ts",
+    "build": "cross-env nuxt-ts build",
+    "start": "cross-env PORT=3000 nuxt-ts start",
+    "generate": "cross-env nuxt-ts generate"
   },
   "dependencies": {
     "@antv/g2": "^3.5.9",
     "@better-scroll/pull-down": "^2.0.0-beta.2",
+    "@nuxt/typescript-runtime": "^1.0.0",
     "@nuxtjs/axios": "^5.3.6",
     "@nuxtjs/proxy": "^1.3.1",
+    "@nuxtjs/router": "^1.5.0",
     "babel-plugin-component": "^1.1.1",
     "better-scroll": "^2.0.0-beta.2",
     "clipboard": "^2.0.6",
@@ -29,7 +31,10 @@
     "jspdf": "^1.5.3",
     "mint-ui": "^2.2.13",
     "moment": "^2.24.0",
+    "murmurhash": "^1.0.0",
     "nuxt": "^2.14.0",
+    "nuxt-buefy": "^0.4.2",
+    "proginn-lib": "ssh://git@www.gitinn.com:proginn/proginn-lib.git",
     "qrcode": "^1.4.4",
     "qs": "^6.8.0",
     "vant": "^2.5.2",
@@ -46,21 +51,26 @@
     "@babel/plugin-transform-runtime": "^7.10.1",
     "@babel/preset-env": "^7.10.1",
     "@babel/preset-react": "^7.10.1",
+    "@nuxt/types": "^2.14.3",
+    "@nuxt/typescript-build": "^2.0.2",
+    "@types/murmurhash": "^0.0.1",
     "babel-loader": "^8.1.0",
     "babel-preset-mobx": "^2.0.0",
     "cheerio": "^1.0.0-rc.3",
     "cross-env": "^5.2.0",
     "css-loader": "^3.4.0",
+    "extract-css-chunks-webpack-plugin": "^4.7.5",
+    "html-webpack-plugin": "^4.3.0",
     "less": "^3.9.0",
     "less-loader": "^5.0.0",
     "node-sass": "^4.12.0",
     "nodemon": "^1.18.9",
+    "pug": "^3.0.0",
+    "pug-plain-loader": "^1.0.0",
     "sass-loader": "^7.1.0",
     "style-loader": "^0.23.1",
     "t2css": "^0.1.6",
-    "webpack": "^4.43.0",
-    "html-webpack-plugin": "^4.3.0",
-    "extract-css-chunks-webpack-plugin": "^4.7.5"
+    "webpack": "^4.43.0"
   },
   "resolutions": {
     "jspdf/file-saver": "1.3.8"

+ 1 - 1
pages/job/index.vue

@@ -131,7 +131,7 @@
           <BottomBanner></BottomBanner>
         </div>
       </div>
-      <SeoFooter style="" :data="footer" />
+      <SeoFooter style :data="footer" />
     </div>
     <div v-else class="jobListMobile">
       <div class="topSelect">

+ 42 - 12
pages/kaifain/case/_tid.vue

@@ -1,5 +1,8 @@
 <template>
   <div :class="{kaifainPreviewCaseMobile: mobile, kaifainPreviewCase: !mobile}">
+    <div class="kaifain-view">
+      <Topnav :fixed="topnavFixed" @publish-click="isShowToast=true" />
+    </div>
     <div class="topArea">
       <div class="bannerBg">
         <img src="~@/assets/img/kaifain/detail/banner@2x.png" alt="" v-if="!mobile">
@@ -25,23 +28,32 @@
     </div>
     <KaifainFooter style="margin-top: 30px;" :data="footer"/>
 
+    <ConnectUs
+      :source="'开发屋案例'"
+      :isShowToast="isShowToast"
+      @close="isShowToast=false"
+      :sourceId="tid"
+    ></ConnectUs>
+
   </div>
 </template>
 
-<script>
+<script lang="ts">
   import "quill/dist/quill.core.css";
   import "quill/dist/quill.snow.css";
-  import ConnectUs from "@/components/common/connectUs"
-  import DealSeoFooter from "@/components/kaifain/dealSeoFooter"
-  import KaifainFooter from "@/components/SeoFooter"
+  import ConnectUs from "@/components/common/connectUsKaifain.vue";
+  import KaifainFooter from "@/components/SeoFooter.vue"
   import {HashIDUtil, GenType} from "../../../plugins/genHashId"
+  import Topnav from '@/kaifain_v2/components/Topnav.vue'
+  import { genDocumentFooterData } from '@/kaifain_v2/helpers/seoHelper'
 
   export default {
     layout: "opacity_header",
-    components: {ConnectUs, KaifainFooter},
+    components: {ConnectUs, KaifainFooter, Topnav},
     head() {
+      const { title = '' } = this.detail || {}
       return {
-        title: this.detail.title + "-开发屋",
+        title: (title ? `${title}-` : '') + '开发屋',
         meta: [ {
           'name': 'keywords',
           'content': '定制化Saas、PasS、API、行业技术解决方案'
@@ -51,10 +63,14 @@
         }, {
           'name': 'h1',
           'content': '开发屋'
-        } ]
+        } ],
+        bodyAttrs: {
+          class: 'non-rem'
+        }
       }
     },
-    async asyncData({...params}) {
+    async asyncData(ctx) {
+      const params = ctx
       try {
         params.store.commit("updateNoneCommonFooter", true)
       } catch (e) {
@@ -76,8 +92,16 @@
         }
       }
 
-      let dealSeoFooterObj = new DealSeoFooter(params)
-      let footer = await dealSeoFooterObj.dealData()
+      if (!ctx.store.state.kaifain.inited) {
+        await ctx.store.dispatch('parameters:load')
+      }
+
+      const { classes } = ctx.store.state.kaifain
+      const footer = genDocumentFooterData({
+        ctx,
+        classes
+      })
+
       let detail = {}
       let errInfo = ""
       let res = await params.$axios.post('/api/kaifawu/get_public_case?id='+ tid, {id: tid})
@@ -95,7 +119,8 @@
         errInfo = res.data && res.data.info || "服务异常"
       }
       return {
-        ...footer,
+        tid,
+        footer,
         mobile: params.app.$deviceType.isMobile(),
         detail,
         errInfo
@@ -103,7 +128,8 @@
     },
     data() {
       return {
-        isShowToast: false
+        isShowToast: false,
+        topnavFixed: false
       }
     },
     created() {
@@ -113,6 +139,10 @@
       if (!this.detail || !this.detail.id) {
         this.$message.error(this.errInfo)
       }
+      const fixHeight = Math.min(520, window.screen.availHeight)
+      window.addEventListener('scroll', () => {
+        this.topnavFixed = window.scrollY > fixHeight
+      })
     },
     computed: {
       haveCase() {

+ 41 - 18
pages/kaifain/detail/_tid/index.vue

@@ -1,5 +1,8 @@
 <template>
   <div :class="{kaifainDetailMobile: mobile, kaifainDetail: !mobile}">
+    <div class="kaifain-view">
+      <Topnav :fixed="topnavFixed" @publish-click="isShowToast=true" />
+    </div>
     <div class="topArea" :style="{backgroundImage: 'url(' + detail.bg_image + ')' }">
       <div class="topContent" :class="{noneMobileAPP: true}">
         <h1 class="title">{{detail.title}}</h1>
@@ -44,7 +47,7 @@
           >{{Number(detail.is_top) !== 0 ? "已置顶" : "列表置顶"}}</div>
         </div>
       </div>
-      <div class="bannerSelect">
+      <!-- <div class="bannerSelect">
         <div class="cell selected">方案首页</div>
         <a
           class="cell"
@@ -54,7 +57,7 @@
           class="cell"
           :href="jishuinUrl + '/c/' + detail.hash_id + '?type=video'"
         >视频({{detail.jishuin && detail.jishuin.videos_count || 0}})</a>
-      </div>
+      </div> -->
       <div class="introArea">
         <div class="title" v-if="!mobile">
           <p class="word">方案介绍</p>
@@ -124,42 +127,49 @@
   </div>
 </template>
 
-<script>
+<script lang="ts">
 import "quill/dist/quill.core.css";
 import "quill/dist/quill.snow.css";
-import ConnectUs from "@/components/common/connectUsKaifain";
-import DealSeoFooter from "@/components/kaifain/dealSeoFooter";
-import ChangeBgImage from "@/components/kaifain/ChangeBgImage";
-import KaifainFooter from "@/components/SeoFooter";
+import ConnectUs from "@/components/common/connectUsKaifain.vue";
+import ChangeBgImage from "@/components/kaifain/ChangeBgImage.vue";
+import KaifainFooter from "@/components/SeoFooter.vue";
 import { HashIDUtil, GenType } from "../../../../plugins/genHashId";
+import Topnav from '@/kaifain_v2/components/Topnav.vue'
+import { genDocumentFooterData } from '@/kaifain_v2/helpers/seoHelper'
 
 export default {
   layout: "opacity_header",
-  components: { ConnectUs, KaifainFooter, ChangeBgImage },
+  components: { ConnectUs, KaifainFooter, ChangeBgImage, Topnav },
   head() {
-    let title = `${this.detail.title}介绍,功能,开发解决方案-开发屋`;
+    const { title = '' } = this.detail || {}
+    let docTitle = title ? `${title}介绍,功能,开发解决方案-开发屋` : '开发屋-开发解决方案'
+
     if (this.$deviceType.app) {
-      title = this.detail.title;
+      docTitle = title
     }
     return {
-      title: title,
+      title: docTitle,
       meta: [
         {
           name: "keywords",
-          content: `${this.detail.title}`,
+          content: title,
         },
         {
           name: "descrption",
-          content: `${this.detail.title}详细介绍,包括不限于功能、接口、企业开发着对接和布局${this.detail.title}的方法和详细的开发文档说明。`,
+          content: `${title}详细介绍,包括不限于功能、接口、企业开发着对接和布局${title}的方法和详细的开发文档说明。`,
         },
         {
           name: "h1",
-          content: `${this.detail.title}`,
+          content: `${title}`,
         },
       ],
+      bodyAttrs: {
+        class: 'non-rem'
+      }
     };
   },
-  async asyncData({ ...params }) {
+  async asyncData(ctx) {
+    const params = ctx
     let { tid: newTid } = params.app.context.route.params || {};
     let tid = newTid.replace(".html", "");
 
@@ -205,8 +215,16 @@ export default {
     } catch (e) {
       console.log("updateNoneCommonFooter", e);
     }
-    let dealSeoFooterObj = new DealSeoFooter(params);
-    let footer = await dealSeoFooterObj.dealData();
+
+    if (!ctx.store.state.kaifain.inited) {
+      await ctx.store.dispatch('parameters:load')
+    }
+
+    const { classes } = ctx.store.state.kaifain
+    const footer = genDocumentFooterData({
+      ctx,
+      classes
+    })
 
     let isSelf = false;
     try {
@@ -223,7 +241,7 @@ export default {
       errInfo,
       mobile: params.app.$deviceType.isMobile(),
       mobileApp: params.app.$deviceType.app,
-      ...footer,
+      footer
     };
   },
   data() {
@@ -234,6 +252,8 @@ export default {
       jishuinUrl: "",
       kaifainUrl: "",
       siteUrl: "",
+      topnavFixed: false,
+      mobile: false
     };
   },
   created() {
@@ -246,6 +266,9 @@ export default {
     if (!this.detail || !this.detail.id) {
       this.$message.error(this.errInfo);
     }
+    window.addEventListener('scroll', () => {
+      this.topnavFixed = window.scrollY > 520
+    })
     // this.getDetail()
   },
   computed: {

+ 2 - 2
pages/otherpage/money/index.vue

@@ -26,7 +26,7 @@
         <div class="agreeInfo" @click="onAgreeProto">
           <div class="choose" :class="{ok: agreeProto}"></div>
           <div class="word">确认并同意</div>
-          <a href="/otherpage/proto/lingxin?key=kaifabao_home" class="word1" @click.stop="()=>{}">《领薪宝服务协议》</a>
+          <a href="/otherpage/proto/lingxin?key=kaifabao_agreement" class="word1" @click.stop="()=>{}">《领薪宝服务协议》</a>
         </div>
         <div class="agreeBtn" @click="onWithdrawBtn">
           <p>提现</p>
@@ -67,7 +67,7 @@
     },
     data() {
       return {
-        agreeProto: false,
+        agreeProto: true,
         bankInfo: {}
       }
     },

+ 6 - 1
plugins/common.js

@@ -1,4 +1,5 @@
 import Vue from 'vue'
+import Cookies from 'js-cookie'
 
 // import http from '@/plugins/http'
 // mixin
@@ -8,6 +9,10 @@ Vue.mixin({
     store,
     req
   }) {
+    if (process.client && !Cookies.get('x_access_token')) {
+      return
+    }
+
     let headers = req && req.headers
     let res = await $axios.$get('/api/user/getInfo', {
       headers
@@ -130,7 +135,7 @@ Vue.mixin({
         })
       }
     },
-    
+
     _toast(msg, type) {
       if (this.$deviceType.isMobile()) {
         this.$toast(msg)

+ 5 - 1
plugins/rem.js

@@ -5,6 +5,10 @@
   var tid;
   var rootItem, rootStyle;
 
+  if (document.body.className.indexOf('non-rem') > -1) {
+    return
+  }
+
   function refreshRem() {
     var width = docEl.getBoundingClientRect().width;
     if (!maxWidth) {
@@ -51,4 +55,4 @@
       doc.body.style.fontSize = "16px";
     }, false);
   }
-})(750, 750);
+})(750, 750);

+ 8 - 7
plugins/router.js

@@ -6,9 +6,10 @@ export default ({ app, context, req, store}) => {
     const isJob = host.indexOf('job') !== -1
     console.log('before Route Path', window.__NUXT__.routePath)
     console.log("app.router.options.routes", app.router.options.routes)
-    if (isKaifain) {
+    /* if (isKaifain) {
       window.__NUXT__.routePath = app.context.route.path.replace(/^\/kaifain/, '/')
-      let kaifainIndex = app.router.options.routes.filter(v => v.name === 'kaifain')[ 0 ]
+      let kaifainIndex = app.router.options.routes.filter(v => v.name === 'kaifain')[0]
+      let kaifainSearch = app.router.options.routes.filter(v => v.name === 'kaifainSearch')[0]
       let kaifainDetail = app.router.options.routes.filter(v => v.name === 'kaifain-detail-tid')[ 0 ]
       let kaifainCaseDetail = app.router.options.routes.filter(v => v.name === 'kaifain-case-tid')[ 0 ]
       let kaifainAdd = app.router.options.routes.filter(v => v.name === 'kaifain-add')[ 0 ]
@@ -57,9 +58,9 @@ export default ({ app, context, req, store}) => {
       } catch ( e ) {
         console.log(e)
       }
-    }
-    
-    if (isJishuin) {
+    } */
+
+    /* if (isJishuin) {
       window.__NUXT__.routePath = app.context.route.path.replace(/^\/jishuin/, '/')
       let jishuinCIndex = app.router.options.routes.filter(v => v.name === "user-collect_article-id-type")[ 0 ]
       let jishuinCType = app.router.options.routes.filter(v => v.name === "user-collect_article-id-type")[ 0 ]
@@ -94,8 +95,8 @@ export default ({ app, context, req, store}) => {
       } catch ( e ) {
         console.log(e)
       }
-    }
-    
+    } */
+
     if (isJob) {
       window.__NUXT__.routePath = app.context.route.path.replace(/^\/job/, '/')
       let jobIndex = app.router.options.routes.filter(v => v.name === 'job')[ 0 ]

+ 68 - 67
plugins/seoRouter.js

@@ -1,71 +1,72 @@
 const extendRoutes = (routes, resolve) => {
-  // /** 解决方案SEO优化 start **/
-  routes.push({
-    name: 'kaifainSeoIndex',
-    path: '/kaifain/*',
-    component: resolve(__dirname, '../pages/kaifain/index.vue')
-  })
-  // routes.unshift({
-  //   name: 'kaifainSeoAll',
-  //   path: '/kaifain/s',
-  //   component: resolve(__dirname, '../pages/kaifain/index.vue')
-  // })
-  //
-  routes.unshift({
-    name: 'kaifainSeoDetail',
-    path: '/kaifain/s/:tid',
-    component: resolve(__dirname, '../pages/kaifain/detail/_tid/index.vue')
-  })
-  routes.unshift({
-    name: 'kaifainCaseSeoDetail',
-    path: '/kaifain/d/:tid',
-    component: resolve(__dirname, '../pages/kaifain/case/_tid.vue')
-  })
-  // /** 解决方案SEO优化 end **/
-  //
-  // /** 兼职招聘SEO优化 start**/
-  routes.unshift({
-    name: 'JobListSeoIndex',
-    path: '/job/*',
-    component: resolve(__dirname, '../pages/job/index.vue')
-  })
-  routes.unshift({
-    name: 'JobListSeoDetail',
-    path: '/job/d/:id?',
-    component: resolve(__dirname, '../pages/job/detail/_id.vue')
-  })
-  routes.unshift({
-    name: 'JobListSeoDetail_0',
-    path: '/job/detail/:id?',
-    component: resolve(__dirname, '../pages/job/detail/_id.vue')
-  })
-  // /** 兼职招聘SEO优化 end**/
-  //
-  //
-  // /*** 技术圈SEO sd改动 start **/
-  // routes.unshift({
-  //   name: 'jishuinCollectedC1',
-  //   path: '/jishuin/c/:id',
-  //   component: resolve(__dirname, '../pages/user/collect_article/_id/_type.vue')
-  // })
-  // routes.unshift({
-  //   name: 'jishuinCollectedCID1',
-  //   path: '/jishuin/c/:id/:type',
-  //   component: resolve(__dirname, '../pages/user/collect_article/_id/_type.vue')
-  // })
-  // //用户
-  // routes.unshift({
-  //   name: 'jishuinUserU1',
-  //   path: '/jishuin/u/:id',
-  //   component: resolve(__dirname, '../pages/user/_id/_type.vue')
-  // })
-  // routes.unshift({
-  //   name: 'jishuinUserUID1',
-  //   path: '/jishuin/u/:id/:type',
-  //   component: resolve(__dirname, '../pages/user/_id/_type.vue')
-  // })
-  // /*** 技术圈SEO sd改动 end  **/
-  
+  // remove auto generate routes
+  const kaifainIndex = routes.findIndex((r) => r.name === 'kaifain')
+
+  kaifainIndex && routes.splice(kaifainIndex, 1)
+
+  routes.unshift(
+    // kaifain
+    ...[{
+      name: 'kaifain',
+      path: '/kaifain',
+      component: resolve(__dirname, '../kaifain_v2/pages/index.vue')
+    }, {
+      name: 'kaifainPage',
+      path: '/kaifain/page/:page',
+      component: resolve(__dirname, '../kaifain_v2/pages/index.vue')
+    }, {
+      name: 'kaifainCategory',
+      path: '/kaifain/c/:cat_id',
+      component: resolve(__dirname, '../kaifain_v2/pages/index.vue')
+    }, {
+      name: 'kaifainSearch',
+      path: '/kaifain/search',
+      component: resolve(__dirname, '../kaifain_v2/pages/search.vue')
+    }, {
+      name: 'kaifainSeoDetail',
+      path: '/kaifain/s/:tid',
+      component: resolve(__dirname, '../pages/kaifain/detail/_tid/index.vue')
+    }, {
+      name: 'kaifainCaseSeoDetail',
+      path: '/kaifain/d/:tid',
+      component: resolve(__dirname, '../pages/kaifain/case/_tid.vue')
+    }],
+
+    // jishuin
+    ...[{
+      name: 'jishuinCollectedCID1',
+      path: '/jishuin/c/:id/:type',
+      component: resolve(__dirname, '../pages/user/collect_article/_id/_type.vue')
+    }, {
+      name: 'jishuinCollectedC1',
+      path: '/jishuin/c/:id',
+      component: resolve(__dirname, '../pages/user/collect_article/_id/_type.vue')
+    }, {
+      name: 'jishuinUserUID1',
+      path: '/jishuin/u/:id/:type',
+      component: resolve(__dirname, '../pages/user/_id/_type.vue')
+    }, {
+      name: 'jishuinUserU1',
+      path: '/jishuin/u/:id',
+      component: resolve(__dirname, '../pages/user/_id/_type.vue')
+    }],
+
+    // job
+    ...[{
+      name: 'JobListSeoDetail_0',
+      path: '/job/detail/:id?',
+      component: resolve(__dirname, '../pages/job/detail/_id.vue')
+    }, {
+      name: 'JobListSeoDetail',
+      path: '/job/d/:id?',
+      component: resolve(__dirname, '../pages/job/detail/_id.vue')
+    }, {
+      name: 'JobListSeoIndex',
+      path: '/job/*',
+      component: resolve(__dirname, '../pages/job/index.vue')
+    }]
+  )
+
   /**
    * 404
    */

+ 59 - 0
router/index.js

@@ -0,0 +1,59 @@
+import Vue from 'vue'
+import Router from 'vue-router'
+
+Vue.use(Router)
+
+const KAIFAIN = 'kaifain'
+const JISHUIN = 'jishuin'
+const RE_IPV4 = /^(\d+.)+\d+$/
+
+const getServerHostname = ({ req }) => {
+  console.log('@ctx.req', req.url)
+  console.log('@ctx.req.headers', req.headers)
+  const { host, referer } = req.headers
+  let hostname = host.split(':')[0]
+
+  if (RE_IPV4.test(hostname)) {
+    hostname = referer && new URL(referer).hostname
+  }
+
+  return hostname
+}
+
+const filterAndRemapRoutesByScope = (routes, scope) => {
+  return [
+    ...routes
+    .filter(r => r.path.startsWith('/' + scope))
+    .map(r => {
+      const clone = { ...r }
+      clone.path = r.path.replace('/' + scope, '') || '/'
+      clone.name = r.name + '_$'
+      return clone
+    }),
+    ...routes
+  ]
+}
+
+export function createRouter(ssrContext, createDefaultRouter, routerOptions) {
+  const options = routerOptions || createDefaultRouter(ssrContext).options
+  const hostname = ssrContext ? getServerHostname(ssrContext) : location.host
+
+  return new Router({
+    ...options,
+    routes: fixRoutes(options.routes, hostname),
+  })
+}
+
+function fixRoutes(defaultRoutes, hostname) {
+  if (hostname && hostname.includes(KAIFAIN)) return kaifainRoutes(defaultRoutes)
+  if (hostname && hostname.includes(JISHUIN)) return jishuinRoutes(defaultRoutes)
+  return defaultRoutes
+}
+
+function kaifainRoutes(defaultRoutes) {
+  return filterAndRemapRoutesByScope(defaultRoutes, KAIFAIN)
+}
+
+function jishuinRoutes(defaultRoutes) {
+  return filterAndRemapRoutesByScope(defaultRoutes, JISHUIN)
+}

+ 10 - 0
store/index.js

@@ -1,4 +1,7 @@
+import * as kaifain from '../kaifain_v2/store'
+
 export const state = () => ({
+  scope: null,
   isPC: -1,
   isWeixin: false,
   userinfo: {},
@@ -57,6 +60,9 @@ export const mutations = {
       ...state.wxConfig,
       ...payload.wxConfig
     };
+  },
+  ['scope:set'](state, val) {
+    state.scope = val
   }
 };
 export const actions = {
@@ -68,3 +74,7 @@ export const actions = {
     commit("updateDeviceType", app.$deviceType || {});
   }
 };
+
+export const modules = {
+  kaifain
+}

+ 43 - 0
tsconfig.json

@@ -0,0 +1,43 @@
+{
+  "compilerOptions": {
+    "target": "ES2018",
+    "module": "ESNext",
+    "moduleResolution": "Node",
+    "noImplicitAny": false,
+    "lib": [
+      "ESNext",
+      "ESNext.AsyncIterable",
+      "DOM"
+    ],
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+    "allowJs": true,
+    "sourceMap": true,
+    "strict": true,
+    "noEmit": true,
+    "experimentalDecorators": true,
+    "baseUrl": ".",
+    "paths": {
+      "~/*": [
+        "./*"
+      ],
+      "@/*": [
+        "./*"
+      ]
+    },
+    "types": [
+      "@types/node",
+      "@nuxt/types"
+    ]
+  },
+  "include": [
+    "store/**/*.ts",
+    "kaifain_v2/**/*.ts",
+    "kaifain_v2/**/*.vue"
+  ],
+  "exclude": [
+    "node_modules",
+    ".nuxt",
+    "dist"
+  ]
+}

Разница между файлами не показана из-за своего большого размера
+ 681 - 35
yarn.lock