Browse Source

BREAKING(refactor): Kaifain V2

Acathur 5 years ago
parent
commit
08f58f10c9

+ 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


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

@@ -0,0 +1,85 @@
+@import "./vars.scss";
+@import "~bulma/sass/utilities/_all";
+
+$fullhd: 1200px+(2 * $gap);
+$primary-invert: findColorInvert($primary);
+
+@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";
+}

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

@@ -0,0 +1 @@
+@import "./icon.scss";

+ 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") .carousel {background: {{list[curIndex] && list[curIndex].theme_color || '#eee'}}} .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">
+.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>

+ 171 - 0
kaifain_v2/components/CategoryNav.vue

@@ -0,0 +1,171 @@
+<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">
+.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;
+  }
+}
+</style>
+

+ 136 - 0
kaifain_v2/components/Pagination.vue

@@ -0,0 +1,136 @@
+<template lang="pug">
+  .pagenav
+    b-pagination(
+      v-if="total > 0"
+      v-model="page"
+      :total="total"
+      :per-page="perPage"
+      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 {
+    }
+  },
+
+  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">
+.pagenav {
+  .hint-msg {
+    text-align: center;
+    font-size: 0.875rem;
+    color: #999;
+    padding: 2rem;
+  }
+}
+
+.pagination {
+  max-width: 540px;
+  margin: 0 auto;
+  justify-content: center;
+  padding: 3rem 0;
+
+  .pagination-list {
+    flex-grow: unset;
+  }
+
+  .progico-arrow {
+    font-size: 0.75em;
+
+    &.left {
+      transform: rotate(180deg);
+    }
+  }
+}
+</style>

+ 143 - 0
kaifain_v2/components/SearchBox.vue

@@ -0,0 +1,143 @@
+<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">
+.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;
+    }
+
+    .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);
+      }
+    }
+  }
+}
+
+</style>

+ 120 - 0
kaifain_v2/components/SolutionCell.vue

@@ -0,0 +1,120 @@
+<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';
+
+.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;
+  }
+
+  .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: 6px 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>

+ 210 - 0
kaifain_v2/components/Toolbar.vue

@@ -0,0 +1,210 @@
+<template lang="pug">
+  .toolbar
+    .container.is-content
+      .level
+        .level-left
+          .level-item
+            b-dropdown(:triggers="['hover']")
+              .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(
+              :triggers="['hover']"
+              custom
+              :class="{'is-force-hide': forceHideCityMenu}"
+              )
+              .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"
+                ) 优选服务
+</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
+    }
+  },
+
+  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)
+      }
+
+      this.forceHideCityMenu = true
+    },
+
+    changeIsChoice(val) {
+      if (val !== this.isChoice) {
+        this.$emit('update:isChoice', val)
+      }
+    }
+  },
+
+  created() {
+    this.isChoiceCache = this.isChoice
+  }
+})
+</script>
+
+<style lang="scss">
+.toolbar {
+  background: #f6f6f7;
+  height: 36px;
+  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;
+  }
+
+  .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;
+    }
+  }
+}
+
+.dropdown.is-hoverable.is-force-hide .dropdown-menu {
+  display: none;
+}
+</style>

+ 239 - 0
kaifain_v2/components/Topnav.vue

@@ -0,0 +1,239 @@
+<template lang="pug">
+  b-navbar.topnav(
+    :fixed-top="fixed"
+    :transparent="true"
+    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}`") 服务商入驻
+      b-navbar-item(:href="`https://${MAIN_HOST}/cat/${tail}`" v-show="!fixed") 程序员
+      b-navbar-dropdown(
+        label="更多"
+        :hoverable="true"
+        :arrowless="true"
+        :boxed="true"
+        )
+        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 登录
+        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
+    }
+  },
+
+  computed: {
+    myInfo() {
+      return this.$store.state.userinfo
+    }
+  },
+
+  methods: {
+    goSearch() {
+      const q = this.qSync
+
+      if (!q) {
+        return
+      }
+
+      this.$router.push(`/search?q=${q}&from=sbl`)
+
+      if (this.$route.name === 'search') {
+        this.$emit('search-change', q)
+      }
+    },
+
+    onPublishClick() {
+      this.$emit('publish-click')
+    }
+  }
+})
+</script>
+
+<style lang="scss">
+$theme-color: #20242d;
+
+.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 0.75rem 0;
+    align-self: center;
+  }
+
+  .navbar-menu {
+    &.is-active {
+      background: $theme-color;
+    }
+  }
+
+  .navbar-item,
+  .navbar-link {
+    background: transparent !important;
+    color: #fff;
+    font-size: 0.875rem;
+  }
+
+  .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-left: 1rem;
+
+    .input.is-small {
+      border-radius: 4px 0 0 4px;
+      min-width: 256px;
+    }
+
+    .button.is-small {
+      border-radius: 0 4px 4px 0;
+      font-weight: 500;
+    }
+  }
+
+  &.is-fixed-top {
+    background: $theme-color;
+    padding: 0;
+    height: 54px;
+
+    .logo {
+      height: 36px;
+    }
+
+    .navbar-item,
+    .navbar-link {
+      font-size: 0.8125rem;
+    }
+
+    .user-widget,
+    .user-widget .nickname {
+      font-size: 13px;
+    }
+  }
+}
+
+@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.is-fixed-top .navbar-menu,
+  .navbar.is-fixed-top-touch .navbar-menu {
+    a.navbar-item {
+      color: inherit;
+    }
+
+    .navbar-split {
+      height: 1px;
+      width: auto;
+      margin: 0.5rem 0.75rem;
+      opacity: 0.2;
+    }
+  }
+}
+</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() {
+      return this.$store.state.domainConfig.siteUrl
+    },
+
+    myInfo() {
+      return this.$store.state.userinfo
+    },
+
+    vipInfo() {
+      let userinfo = this.$store.state.userinfo
+      return {
+        id: userinfo.vip_type_id,
+        endDate: userinfo.vip_end_date,
+      }
+    },
+
+    vipImage() {
+      switch (parseInt(this.$store.state.userinfo.vip_type_id)) {
+        case 1:
+          return "_com";
+        case 2:
+          return "";
+        case 3:
+          return "_premium";
+        default:
+          return "";
+      }
+    },
+    vipType() {
+      switch (parseInt(this.$store.state.userinfo.vip_type_id)) {
+        case 1:
+        case 3:
+          return "enterprise";
+        case 2:
+          return "developer";
+      }
+    },
+    vipTextClass() {
+      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() {
+      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() {
+      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">
+.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;
+  }
+
+  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;
+  }
+  .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;
+}
+.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'

+ 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: `/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
+    }
+  }
+})

+ 226 - 0
kaifain_v2/pages/index.vue

@@ -0,0 +1,226 @@
+<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"
+        )
+      #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 ConnectUs from '@/components/common/connectUs.vue'
+import KaifainFooter from '@/components/SeoFooter.vue'
+import DealSeoData from '@/components/kaifain/dealSeoIndex'
+import DealSeoFooter from '@/components/kaifain/dealSeoFooter'
+
+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
+    }
+  },
+
+  head() {
+    let {
+      title = "",
+      keyword = "",
+      descrption = "",
+      h1 = "",
+      canonical = "",
+      metaLocation,
+    } = this.head || {};
+
+    let obj = {
+      title: title,
+      meta: [
+        {
+          name: "keywords",
+          content: keyword,
+        },
+        {
+          name: "descrption",
+          content: descrption,
+        },
+        {
+          name: "h1",
+          content: h1,
+        },
+      ],
+      link: [{ rel: "canonical", href: canonical }],
+    };
+    if (metaLocation) {
+      obj.meta.push({ name: "location", content: metaLocation });
+    }
+    return obj;
+  },
+
+  async asyncData(ctx: any) {
+    const { app, store, params, query, error } = ctx
+    const {
+      page = 0,
+      cat_id
+    } = params as any
+    const {
+      sort = 0,
+      city_id,
+      is_choice,
+      st
+    } = query as any
+
+    if (!store.state.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,
+      catId,
+      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)' })
+    }
+
+    let dealSeoFooterObj = new DealSeoFooter(ctx)
+    let footer = await dealSeoFooterObj.dealData()
+
+    return {
+      page: ~~page,
+      sortType: ~~sort,
+      topCatId,
+      catId,
+      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">
+.home {
+  .main {
+    min-height: 480px;
+  }
+}
+</style>

+ 155 - 0
kaifain_v2/pages/search.vue

@@ -0,0 +1,155 @@
+<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"
+        )
+    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 ConnectUs from '@/components/common/connectUs.vue'
+
+export default SolutionListMixin.extend({
+  name: 'Search',
+  components: {
+    Topnav,
+    Carousel,
+    SearchBox,
+    CategoryNav,
+    Toolbar,
+    SolutionCell,
+    Pagination,
+    ConnectUs
+  },
+
+  layout: 'kaifain_v2',
+
+  data() {
+    return {
+      topnavFixed: true,
+      connectPopupVisible: false
+    }
+  },
+
+  watch: {
+    ['$route.query.page'](val) {
+      if (val) {
+        this.getSolutionList({
+          page: ~~val,
+          updateUrl: false
+        })
+          .then(() => {
+            scrollToElement('#view')
+          })
+      }
+    }
+  },
+
+  async asyncData({ query, error }) {
+    const {
+      q = '',
+      page = 0,
+      sort = 0,
+      city_id,
+      is_choice
+    } = query as any
+    const { cities, classes, serviceTypes } = await getInitParameters()
+    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)' })
+    }
+
+    return {
+      q,
+      page: ~~page,
+      sortType: ~~sort,
+      cityId: city_id || null,
+      isChoice: !!is_choice || false,
+      cities,
+      classes,
+      serviceTypes,
+      solutionList: solutionRes.list || [],
+      solutionTotal: ~~solutionRes.total
+    }
+  },
+
+  methods: {
+    async onSearchChange(val) {
+      if (!val) {
+        return
+      }
+
+      this.q = val
+      await this.getSolutionList({
+        reset: true
+      })
+    }
+  }
+})
+</script>
+
+<style lang="scss">
+.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


+ 71 - 0
kaifain_v2/store/index.ts

@@ -0,0 +1,71 @@
+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: '全部'
+          })
+        }
+
+        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
+  }
+}

+ 77 - 0
kaifain_v2/utils/misc.ts

@@ -0,0 +1,77 @@
+export const scrollToElement = (selector: string) => {
+  const el = document.querySelector(selector)
+
+  if (el) {
+    setTimeout(() => {
+      el.scrollIntoView({
+        behavior: 'smooth'
+      })
+    }, 17)
+  }
+}
+
+export const findTopCatBySubCatId = (classes, catId) => {
+  return classes.find((topCat) => {
+    const cats = topCat.categories
+    return cats && cats.length && cats.find((cat) => cat.cat_id === catId)
+  })
+}
+
+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[] = []
+
+  if (cat_id) {
+    let topCat
+
+    if (cat_id.length === 8) {
+      topCat = classes.find((topCat) => topCat.hash_id === cat_id)
+      matchedCatIds = getTopCatSubCatIds(topCat)
+    } else {
+      catId = cat_id
+      matchedCatIds = [catId]
+      topCat = findTopCatBySubCatId(classes, catId)
+    }
+
+    if (topCat) {
+      topCatId = topCat.hash_id
+    }
+  }
+
+  if (st) {
+    const matchedServiceType = serviceTypes.find((item) => item.hash_id === st)
+
+    if (matchedServiceType) {
+      serviceType = st
+    }
+  }
+
+  return {
+    topCatId,
+    catId,
+    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

+ 37 - 0
layouts/kaifain_v2.vue

@@ -0,0 +1,37 @@
+<template>
+  <nuxt id="kaifain-view" />
+</template>
+
+<style lang="scss">
+@import '@/kaifain_v2/assets/styles/buefy.scss';
+@import '@/kaifain_v2/assets/styles/main.scss';
+
+:root {
+  font-size: 16px;
+}
+
+body {
+  background: #fff;
+}
+
+#footer {
+  padding: 0 0 1rem;
+}
+
+#kaifain-view {
+  .container {
+    display: block;
+  }
+
+  .navbar {
+    &> .container {
+      display: flex;
+      flex-direction: row;
+    }
+  }
+}
+
+.connectUs .title {
+  margin: 0;
+}
+</style>

+ 12 - 2
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
@@ -236,10 +237,19 @@ module.exports = {
     ]
   ],
 
+  buildModules: ['@nuxt/typescript-build'],
+
   /*
    ** Nuxt.js modules
    */
-  modules: ["@nuxtjs/axios", "@nuxtjs/proxy"],
+  modules: [
+    "@nuxtjs/axios",
+    "@nuxtjs/proxy",
+    ["nuxt-buefy", {
+      css: false,
+      materialDesignIcons: false
+    }]
+  ],
   router: {
     middleware: ["initialize"],
     ...seoRouter

+ 15 - 8
package.json

@@ -6,15 +6,16 @@
   "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",
     "babel-plugin-component": "^1.1.1",
@@ -30,6 +31,8 @@
     "mint-ui": "^2.2.13",
     "moment": "^2.24.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 +49,25 @@
     "@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",
     "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"

+ 5 - 1
plugins/rem.js

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

+ 21 - 4
plugins/seoRouter.js

@@ -1,10 +1,27 @@
 const extendRoutes = (routes, resolve) => {
+  // remove auto generate routes
+  const kaifainIndex = routes.findIndex((r) => r.name === 'kaifain')
+
+  kaifainIndex && routes.splice(kaifainIndex, 1)
+
   // /** 解决方案SEO优化 start **/
-  routes.push({
+  routes.push(...[{
+    name: 'kaifain',
+    path: '/kaifain',
+    component: resolve(__dirname, '../kaifain_v2/pages/index.vue')
+  }, {
     name: 'kaifainSeoIndex',
     path: '/kaifain/*',
-    component: resolve(__dirname, '../pages/kaifain/index.vue')
-  })
+    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')
+  }])
   // routes.unshift({
   //   name: 'kaifainSeoAll',
   //   path: '/kaifain/s',
@@ -65,7 +82,7 @@ const extendRoutes = (routes, resolve) => {
   //   component: resolve(__dirname, '../pages/user/_id/_type.vue')
   // })
   // /*** 技术圈SEO sd改动 end  **/
-  
+
   /**
    * 404
    */

+ 6 - 0
store/index.js

@@ -1,3 +1,5 @@
+import * as kaifain from '../kaifain_v2/store'
+
 export const state = () => ({
   isPC: -1,
   isWeixin: false,
@@ -68,3 +70,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"
+  ]
+}

File diff suppressed because it is too large
+ 664 - 35
yarn.lock